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
@@ -29,6 +29,7 @@ KnowledgeProjectMatchKind = Literal[
29
29
  'exact_duplicate',
30
30
  'conflict',
31
31
  ]
32
+ PersonalProjectRelationStatus = Literal['pending', 'active', 'rejected']
32
33
 
33
34
 
34
35
  class KnowledgeProjectPreviewRequest(StrictModel):
@@ -140,3 +141,24 @@ class KnowledgeProjectActionResult(StrictModel):
140
141
  match: KnowledgeProjectMatch
141
142
  reference: KnowledgeProjectReferenceRecord | None = None
142
143
  conflict: KnowledgeConflictRecord | None = None
144
+
145
+
146
+ class PersonalProjectRelationReviewRequest(StrictModel):
147
+ decision: Literal['activate', 'reject']
148
+ decision_revision: int = Field(ge=0)
149
+ reason: str = Field(min_length=1, max_length=2000)
150
+
151
+
152
+ class PersonalProjectRelationReviewRecord(StrictModel):
153
+ relation_id: str
154
+ personal_space_id: str
155
+ source_project_id: str
156
+ target_project_id: str
157
+ relation_type: ProjectRelationType
158
+ status: PersonalProjectRelationStatus
159
+ confirmation_authority: Literal['human_review'] | None = None
160
+ decision_revision: int = Field(ge=1)
161
+ reviewed_by: str
162
+ reviewed_at: datetime
163
+ review_reason: str
164
+ review_event_id: str
@@ -23,8 +23,11 @@ from .project_action_models import (
23
23
  KnowledgeProjectMatch,
24
24
  KnowledgeProjectPreviewRecord,
25
25
  KnowledgeProjectPreviewRequest,
26
+ PersonalProjectRelationReviewRecord,
27
+ PersonalProjectRelationReviewRequest,
26
28
  )
27
29
  from .provider_values import (
30
+ native_datetime as _native_datetime,
28
31
  normalized_text as _normalized,
29
32
  stable_uuid as _stable_uuid,
30
33
  )
@@ -209,7 +212,10 @@ async def _record_project_action_if_human(
209
212
  item_id,
210
213
  request,
211
214
  ):
212
- if request.operation_actor != 'human':
215
+ if (
216
+ request.operation_actor != 'human'
217
+ or actor.get('_human_review_verified') is not True
218
+ ):
213
219
  return
214
220
  await record_human_change(
215
221
  store,
@@ -295,6 +301,70 @@ async def read_personal_project_relations(store, space_id: str) -> list[dict]:
295
301
  ]
296
302
 
297
303
 
304
+ async def review_personal_project_relation(
305
+ store,
306
+ actor: dict,
307
+ space_id: str,
308
+ relation_id: str,
309
+ request: PersonalProjectRelationReviewRequest,
310
+ ) -> PersonalProjectRelationReviewRecord:
311
+ store._require_personal()
312
+ space = await store.authorize(actor, space_id, 'maintainer')
313
+ if space['kind'] != 'personal':
314
+ raise HTTPException(
315
+ status_code=422,
316
+ detail='personal project relation review is personal-only',
317
+ )
318
+ reviewed_at = datetime.now(timezone.utc)
319
+ next_revision = request.decision_revision + 1
320
+ status = 'active' if request.decision == 'activate' else 'rejected'
321
+ review_event_id = _stable_uuid(
322
+ space_id,
323
+ 'personal-project-relation-review',
324
+ relation_id,
325
+ str(next_revision),
326
+ request.decision,
327
+ )
328
+ records, _, _ = await store.runtime.driver.execute_query(
329
+ _REVIEW_PERSONAL_PROJECT_RELATION_QUERY,
330
+ space_id=space_id,
331
+ relation_id=relation_id,
332
+ expected_revision=request.decision_revision,
333
+ next_revision=next_revision,
334
+ status=status,
335
+ confirmation_authority=(
336
+ 'human_review' if status == 'active' else None
337
+ ),
338
+ reviewed_by=actor['id'],
339
+ reviewed_at=reviewed_at,
340
+ review_reason=request.reason,
341
+ review_event_id=review_event_id,
342
+ )
343
+ if not records:
344
+ raise HTTPException(
345
+ status_code=409,
346
+ detail=(
347
+ 'personal project relation is missing or its review revision '
348
+ 'changed; refresh before deciding'
349
+ ),
350
+ )
351
+ value = dict(records[0])
352
+ return PersonalProjectRelationReviewRecord(
353
+ relation_id=value['relation_id'],
354
+ personal_space_id=space_id,
355
+ source_project_id=value['source_project_id'],
356
+ target_project_id=value['target_project_id'],
357
+ relation_type=value['relation_type'],
358
+ status=value['status'],
359
+ confirmation_authority=value.get('confirmation_authority'),
360
+ decision_revision=int(value['decision_revision']),
361
+ reviewed_by=value['reviewed_by'],
362
+ reviewed_at=_native_datetime(value['reviewed_at']),
363
+ review_reason=value['review_reason'],
364
+ review_event_id=value['review_event_id'],
365
+ )
366
+
367
+
298
368
  async def _authorize_item(store, actor, item_id, request):
299
369
  store._require_personal()
300
370
  space = await store.authorize(actor, request.personal_space_id, 'maintainer')
@@ -631,7 +701,13 @@ async def _maybe_create_project_relation(
631
701
  relation_type: $relation_type
632
702
  }]->(target)
633
703
  ON CREATE SET relation.created_at = $changed_at,
634
- relation.created_by = $created_by
704
+ relation.created_by = $created_by,
705
+ relation.status = 'pending',
706
+ relation.confirmation_authority = null,
707
+ relation.decision_revision = 0,
708
+ relation.reviewed_by = null,
709
+ relation.reviewed_at = null,
710
+ relation.review_reason = null
635
711
  SET relation.updated_at = $changed_at
636
712
  RETURN relation.id AS id
637
713
  ''',
@@ -646,6 +722,49 @@ async def _maybe_create_project_relation(
646
722
  return bool(records)
647
723
 
648
724
 
725
+ _REVIEW_PERSONAL_PROJECT_RELATION_QUERY = '''
726
+ /* fuli:review-personal-project-relation */
727
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})-
728
+ [:CONTAINS_PROJECT]->
729
+ (source:FuliPersonalProject)-
730
+ [relation:PERSONAL_PROJECT_RELATION {id: $relation_id}]->
731
+ (target:FuliPersonalProject)
732
+ SET relation.cas_lock = coalesce(relation.cas_lock, 0) + 1
733
+ WITH space, source, target, relation
734
+ WHERE coalesce(relation.decision_revision, 0) = $expected_revision
735
+ SET relation.status = $status,
736
+ relation.confirmation_authority = $confirmation_authority,
737
+ relation.decision_revision = $next_revision,
738
+ relation.reviewed_by = $reviewed_by,
739
+ relation.reviewed_at = $reviewed_at,
740
+ relation.review_reason = $review_reason,
741
+ relation.updated_at = $reviewed_at
742
+ CREATE (review:FuliPersonalProjectRelationReview {
743
+ id: $review_event_id,
744
+ space_id: $space_id,
745
+ relation_id: $relation_id,
746
+ decision_revision: $next_revision,
747
+ decision: $status,
748
+ confirmation_authority: 'human_review',
749
+ reason: $review_reason,
750
+ reviewed_by: $reviewed_by,
751
+ reviewed_at: $reviewed_at
752
+ })
753
+ MERGE (space)-[:HAS_PERSONAL_PROJECT_RELATION_REVIEW]->(review)
754
+ RETURN relation.id AS relation_id,
755
+ source.project_id AS source_project_id,
756
+ target.project_id AS target_project_id,
757
+ relation.relation_type AS relation_type,
758
+ relation.status AS status,
759
+ relation.confirmation_authority AS confirmation_authority,
760
+ relation.decision_revision AS decision_revision,
761
+ relation.reviewed_by AS reviewed_by,
762
+ relation.reviewed_at AS reviewed_at,
763
+ relation.review_reason AS review_reason,
764
+ review.id AS review_event_id
765
+ '''
766
+
767
+
649
768
  def _relation_endpoints(source_project_id, target_project_id, request):
650
769
  """Keep an extracted project as the child side of directional relations."""
651
770
  if request.mode == 'create':
@@ -36,5 +36,29 @@ def json_object(value) -> dict:
36
36
  return parsed if isinstance(parsed, dict) else {}
37
37
 
38
38
 
39
+ _PREFERENCE_QUALIFIER_EXCLUSIONS = frozenset({
40
+ 'preferenceKey',
41
+ 'preference_key',
42
+ 'searchTerms',
43
+ 'search_terms',
44
+ 'weight',
45
+ })
46
+
47
+
48
+ def preference_qualifiers(value) -> dict:
49
+ """Project content qualifiers safe to expose and bind to review state.
50
+
51
+ Retrieval aliases, ranking hints, and the separately projected semantic key
52
+ are intentionally omitted. Remaining JSON attributes preserve source-level
53
+ qualifications such as language, audience, conditions, and rationale.
54
+ """
55
+ attributes = json_object(value)
56
+ return {
57
+ str(key): attributes[key]
58
+ for key in sorted(attributes, key=str)
59
+ if key not in _PREFERENCE_QUALIFIER_EXCLUSIONS
60
+ }
61
+
62
+
39
63
  def normalized_text(value) -> str:
40
64
  return ' '.join(str(value or '').strip().casefold().split())
@@ -181,6 +181,60 @@ class GraphitiRuntime:
181
181
  'CREATE CONSTRAINT fuli_knowledge_review_decision_id IF NOT EXISTS '
182
182
  'FOR (n:FuliKnowledgeReviewDecision) REQUIRE n.id IS UNIQUE'
183
183
  )
184
+ await self.driver.execute_query(
185
+ 'CREATE CONSTRAINT fuli_workflow_candidate_id IF NOT EXISTS '
186
+ 'FOR (n:FuliWorkflowCandidate) REQUIRE n.id IS UNIQUE'
187
+ )
188
+ await self.driver.execute_query(
189
+ 'CREATE CONSTRAINT fuli_workflow_review_event_id IF NOT EXISTS '
190
+ 'FOR (n:FuliWorkflowReviewEvent) REQUIRE n.id IS UNIQUE'
191
+ )
192
+ await self.driver.execute_query(
193
+ 'CREATE CONSTRAINT fuli_workflow_review_preview_id IF NOT EXISTS '
194
+ 'FOR (n:FuliWorkflowReviewPreview) REQUIRE n.id IS UNIQUE'
195
+ )
196
+ await self.driver.execute_query(
197
+ 'CREATE CONSTRAINT fuli_workflow_review_preview_token IF NOT EXISTS '
198
+ 'FOR (n:FuliWorkflowReviewPreview) REQUIRE n.token_hash IS UNIQUE'
199
+ )
200
+ await self.driver.execute_query(
201
+ 'CREATE CONSTRAINT fuli_workflow_rule_id IF NOT EXISTS '
202
+ 'FOR (n:FuliWorkflowRule) REQUIRE n.id IS UNIQUE'
203
+ )
204
+ await self.driver.execute_query(
205
+ 'CREATE CONSTRAINT fuli_workflow_authorization_id IF NOT EXISTS '
206
+ 'FOR (n:FuliWorkflowAuthorization) REQUIRE n.id IS UNIQUE'
207
+ )
208
+ await self.driver.execute_query(
209
+ 'CREATE CONSTRAINT fuli_personal_global_preference_decision '
210
+ 'IF NOT EXISTS '
211
+ 'FOR (n:FuliPersonalGlobalPreferenceDecision) '
212
+ 'REQUIRE (n.space_id, n.candidate_id) IS UNIQUE'
213
+ )
214
+ await self.driver.execute_query(
215
+ 'CREATE CONSTRAINT fuli_personal_global_preference_event '
216
+ 'IF NOT EXISTS '
217
+ 'FOR (n:FuliPersonalGlobalPreferenceDecisionEvent) '
218
+ 'REQUIRE n.id IS UNIQUE'
219
+ )
220
+ await self.driver.execute_query(
221
+ 'CREATE CONSTRAINT fuli_personal_global_preference_preview '
222
+ 'IF NOT EXISTS '
223
+ 'FOR (n:FuliPersonalGlobalPreferenceDecisionPreview) '
224
+ 'REQUIRE n.id IS UNIQUE'
225
+ )
226
+ await self.driver.execute_query(
227
+ 'CREATE CONSTRAINT fuli_personal_global_preference_preview_token '
228
+ 'IF NOT EXISTS '
229
+ 'FOR (n:FuliPersonalGlobalPreferenceDecisionPreview) '
230
+ 'REQUIRE n.token_hash IS UNIQUE'
231
+ )
232
+ await self.driver.execute_query(
233
+ 'CREATE CONSTRAINT fuli_personal_project_relation_review '
234
+ 'IF NOT EXISTS '
235
+ 'FOR (n:FuliPersonalProjectRelationReview) '
236
+ 'REQUIRE n.id IS UNIQUE'
237
+ )
184
238
  for query in (
185
239
  'CREATE INDEX fuli_entity_confirmation IF NOT EXISTS '
186
240
  'FOR (n:Entity) ON (n.group_id, n.fuli_confirmation_status)',
@@ -208,10 +262,34 @@ class GraphitiRuntime:
208
262
  ):
209
263
  await self.driver.execute_query(query)
210
264
  await self._migrate_knowledge_lifecycle_defaults()
265
+ await self._migrate_personal_project_relation_review_defaults()
211
266
  await self._migrate_space_visibility_and_owners()
212
267
  await self._migrate_project_releases()
213
268
  await self._migrate_legacy_group_ids()
214
269
 
270
+ async def _migrate_personal_project_relation_review_defaults(self) -> None:
271
+ await self.driver.execute_query(
272
+ '''
273
+ MATCH ()-[relation:PERSONAL_PROJECT_RELATION]->()
274
+ WHERE relation.status IS NULL
275
+ OR relation.decision_revision IS NULL
276
+ OR relation.confirmation_authority IS NULL
277
+ SET relation.status = CASE
278
+ WHEN relation.status IN ['active', 'rejected']
279
+ THEN relation.status
280
+ ELSE 'pending'
281
+ END,
282
+ relation.decision_revision =
283
+ coalesce(relation.decision_revision, 0),
284
+ relation.confirmation_authority = CASE
285
+ WHEN relation.status = 'active'
286
+ AND relation.confirmation_authority = 'human_review'
287
+ THEN 'human_review'
288
+ ELSE null
289
+ END
290
+ '''
291
+ )
292
+
215
293
  async def _migrate_space_visibility_and_owners(self) -> None:
216
294
  await self.driver.execute_query(
217
295
  "MATCH (s:FuliSpace) WHERE s.visibility IS NULL "
@@ -7,7 +7,11 @@ from .models import (
7
7
  FactResult,
8
8
  _validate_confirmation_state,
9
9
  )
10
- from .provider_values import json_object, native_datetime as _native_datetime
10
+ from .provider_values import (
11
+ json_object,
12
+ native_datetime as _native_datetime,
13
+ preference_qualifiers,
14
+ )
11
15
 
12
16
 
13
17
  def is_default_retrievable(metadata: dict) -> bool:
@@ -54,6 +58,7 @@ async def read_edge_epistemic_metadata(store, edge_ids: list[str]) -> dict[str,
54
58
  AS preference_scope,
55
59
  edge.fuli_preference_project_id AS preference_project_id,
56
60
  edge.fuli_key AS key,
61
+ edge.fuli_attributes_json AS attributes_json,
57
62
  coalesce(edge.fuli_inheritance_mode, 'local_only')
58
63
  AS inheritance_mode,
59
64
  coalesce(edge.fuli_inherited_project_ids, [])
@@ -111,6 +116,7 @@ def fact_result(
111
116
  epistemic: dict | None = None,
112
117
  ) -> FactResult:
113
118
  metadata = epistemic or {}
119
+ attributes = json_object(metadata.get('attributes_json'))
114
120
  return FactResult(
115
121
  id=edge.uuid,
116
122
  space_id=space_id,
@@ -135,6 +141,13 @@ def fact_result(
135
141
  preference_scope=metadata.get('preference_scope'),
136
142
  preference_project_id=metadata.get('preference_project_id'),
137
143
  key=metadata.get('key'),
144
+ preference_key=(
145
+ attributes.get('preferenceKey')
146
+ or attributes.get('preference_key')
147
+ or metadata.get('key')
148
+ or edge.uuid
149
+ ),
150
+ preference_qualifiers=preference_qualifiers(attributes),
138
151
  defined_project_id=metadata.get('defined_project_id'),
139
152
  inheritance_mode=metadata.get('inheritance_mode') or 'local_only',
140
153
  inherited_project_ids=metadata.get('inherited_project_ids') or [],
@@ -17,6 +17,7 @@ from .models import (
17
17
  )
18
18
  from .personal_project_access import authorize_personal_project
19
19
  from .provider_values import now_utc, stable_uuid
20
+ from .workflow_candidates import materialize_workflow_candidates
20
21
 
21
22
 
22
23
  class StoreKnowledge:
@@ -35,6 +36,29 @@ class StoreKnowledge:
35
36
  personal_project_id=request.personal_project_id,
36
37
  )
37
38
 
39
+ async def commit_workflow_observation(
40
+ self,
41
+ actor: dict,
42
+ request: KnowledgeCommit,
43
+ ) -> CommitResult:
44
+ self._require_personal()
45
+ space = await self.authorize(actor, request.space_id, 'maintainer')
46
+ if space['kind'] != 'personal':
47
+ raise HTTPException(
48
+ status_code=422,
49
+ detail='workflow observations are personal-only',
50
+ )
51
+ if request.personal_project_id:
52
+ await authorize_personal_project(
53
+ self, actor, space, request.personal_project_id
54
+ )
55
+ return await self._commit_episode(
56
+ space,
57
+ request.episode,
58
+ personal_project_id=request.personal_project_id,
59
+ workflow_session_authority='mcp_host',
60
+ )
61
+
38
62
  async def search(self, actor: dict, request: SearchRequest) -> SearchResult:
39
63
  return await search_knowledge(self, actor, request)
40
64
 
@@ -71,6 +95,7 @@ class StoreKnowledge:
71
95
  episode: StructuredEpisode,
72
96
  *,
73
97
  personal_project_id: str | None = None,
98
+ workflow_session_authority: str | None = None,
74
99
  ) -> CommitResult:
75
100
  group_id = space['group_id']
76
101
  lock = self._group_locks.setdefault(group_id, asyncio.Lock())
@@ -90,6 +115,12 @@ class StoreKnowledge:
90
115
  )
91
116
  for entity in episode.entities
92
117
  ]
118
+ entity_id_by_key = {
119
+ entity.key: entity_id
120
+ for entity, entity_id in zip(
121
+ episode.entities, entity_ids, strict=True
122
+ )
123
+ }
93
124
  relationship_ids = [
94
125
  _relationship_id(
95
126
  group_id,
@@ -98,10 +129,40 @@ class StoreKnowledge:
98
129
  relationship.key,
99
130
  relationship.fact,
100
131
  (relationship.valid_at or episode.reference_time).isoformat(),
132
+ workflow_session_authority=workflow_session_authority,
101
133
  )
102
134
  for relationship in episode.relationships
103
135
  ]
136
+ entity_input_by_key = {
137
+ entity.key: entity for entity in episode.entities
138
+ }
139
+ workflow_pairs = [
140
+ {
141
+ 'workflow_key': relationship.key,
142
+ 'source_step_id': entity_id_by_key[relationship.source],
143
+ 'source_step_key': relationship.source,
144
+ 'source_step_name': (
145
+ entity_input_by_key[relationship.source].name
146
+ ),
147
+ 'target_step_id': entity_id_by_key[relationship.target],
148
+ 'target_step_key': relationship.target,
149
+ 'target_step_name': (
150
+ entity_input_by_key[relationship.target].name
151
+ ),
152
+ 'condition_json': _workflow_condition_json(
153
+ relationship.attributes
154
+ ),
155
+ }
156
+ for relationship in episode.relationships
157
+ if relationship.type == 'RECOMMENDS_NEXT'
158
+ ]
104
159
  if existing:
160
+ await materialize_workflow_candidates(
161
+ self,
162
+ space,
163
+ personal_project_id=personal_project_id,
164
+ pairs=workflow_pairs,
165
+ )
105
166
  return CommitResult(
106
167
  status='duplicate',
107
168
  space_id=space['id'],
@@ -210,6 +271,15 @@ class StoreKnowledge:
210
271
  'attributes_json': json.dumps(
211
272
  relationship.attributes, ensure_ascii=False, sort_keys=True
212
273
  ),
274
+ 'workflow_condition_json': _workflow_condition_json(
275
+ relationship.attributes
276
+ ),
277
+ 'workflow_confirmation_authority': (
278
+ _workflow_confirmation_authority(relationship)
279
+ ),
280
+ 'workflow_session_authority': (
281
+ workflow_session_authority
282
+ ),
213
283
  }
214
284
  )
215
285
 
@@ -276,7 +346,9 @@ class StoreKnowledge:
276
346
  fuli_summary: $summary,
277
347
  fuli_sensitivity: $sensitivity,
278
348
  fuli_personal_project_id: $personal_project_id,
279
- fuli_idempotency_key: $idempotency_key
349
+ fuli_idempotency_key: $idempotency_key,
350
+ fuli_workflow_session_authority:
351
+ $workflow_session_authority
280
352
  })
281
353
  WITH episode
282
354
  UNWIND $entities AS row
@@ -316,7 +388,13 @@ class StoreKnowledge:
316
388
  edge.fuli_inherited_project_ids = row.inherited_project_ids,
317
389
  edge.fuli_preference_scope = row.preference_scope,
318
390
  edge.fuli_preference_project_id = row.preference_project_id,
319
- edge.fuli_attributes_json = row.attributes_json
391
+ edge.fuli_attributes_json = row.attributes_json,
392
+ edge.fuli_workflow_condition_json =
393
+ row.workflow_condition_json,
394
+ edge.fuli_workflow_confirmation_authority =
395
+ row.workflow_confirmation_authority,
396
+ edge.fuli_workflow_session_authority =
397
+ row.workflow_session_authority
320
398
  SET edge.episodes =
321
399
  CASE WHEN $episode_id IN coalesce(edge.episodes, [])
322
400
  THEN edge.episodes
@@ -400,8 +478,15 @@ class StoreKnowledge:
400
478
  summary=episode.summary,
401
479
  sensitivity=episode.sensitivity,
402
480
  personal_project_id=personal_project_id,
481
+ workflow_session_authority=workflow_session_authority,
403
482
  idempotency_key=episode.idempotency_key,
404
483
  )
484
+ await materialize_workflow_candidates(
485
+ self,
486
+ space,
487
+ personal_project_id=personal_project_id,
488
+ pairs=workflow_pairs,
489
+ )
405
490
  return CommitResult(
406
491
  status='committed',
407
492
  space_id=space['id'],
@@ -419,6 +504,28 @@ def _initial_confidence(status: str) -> float:
419
504
  }[status]
420
505
 
421
506
 
507
+ def _workflow_condition_json(attributes: dict) -> str:
508
+ condition = attributes.get(
509
+ 'workflowCondition',
510
+ attributes.get('workflow_condition', {}),
511
+ )
512
+ if not isinstance(condition, dict):
513
+ condition = {}
514
+ return json.dumps(condition, ensure_ascii=False, sort_keys=True)
515
+
516
+
517
+ def _workflow_confirmation_authority(relationship) -> str:
518
+ basis = relationship.confirmation_basis
519
+ if relationship.confirmation_status == 'confirmed' and basis.confirmed_by:
520
+ return basis.confirmed_by.kind
521
+ return {
522
+ 'agent': 'agent_proposed',
523
+ 'import': 'import_proposed',
524
+ 'user': 'user',
525
+ 'authoritative_source': 'authoritative_source',
526
+ }[basis.proposed_by.kind]
527
+
528
+
422
529
  def _entity_id(
423
530
  group_id: str,
424
531
  space_kind: str,
@@ -443,8 +550,15 @@ def _relationship_id(
443
550
  key: str,
444
551
  fact: str,
445
552
  reference_time: str,
553
+ *,
554
+ workflow_session_authority: str | None = None,
446
555
  ) -> str:
447
556
  parts = [group_id, 'relationship']
448
557
  if space_kind == 'personal' and personal_project_id:
449
558
  parts.extend(['personal-project', personal_project_id])
559
+ if workflow_session_authority:
560
+ parts.extend([
561
+ 'workflow-session-authority',
562
+ workflow_session_authority,
563
+ ])
450
564
  return stable_uuid(*parts, key, fact, reference_time)