fuli-context 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +178 -0
  3. package/compose.graphiti.yml +86 -0
  4. package/dist/web/assets/ConnectionsPage-CY6H_mJJ.js +1 -0
  5. package/dist/web/assets/KnowledgeInspector-BSpz5Sfm.js +1 -0
  6. package/dist/web/assets/KnowledgeInspector-CHbB_0rC.css +1 -0
  7. package/dist/web/assets/KnowledgeOrganizerPage-BlEvhPrv.css +1 -0
  8. package/dist/web/assets/KnowledgeOrganizerPage-CvjGPlUc.js +1 -0
  9. package/dist/web/assets/KnowledgePage-Khuo3cNs.js +1 -0
  10. package/dist/web/assets/KnowledgeWorkspace-DVZusvem.js +4 -0
  11. package/dist/web/assets/OverviewPage-DC4C4Fhn.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-CSV9Mtgz.js +1 -0
  13. package/dist/web/assets/PersonalProfilePage-CVJ3TcRi.css +1 -0
  14. package/dist/web/assets/PersonalProjectsPage-A50EaseQ.js +1 -0
  15. package/dist/web/assets/PublicProjectsPage-BAdO_bae.js +1 -0
  16. package/dist/web/assets/ReviewPage-Dztk2jDG.js +1 -0
  17. package/dist/web/assets/console-tbvTUi6p.js +1 -0
  18. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  19. package/dist/web/assets/identity-B3s5CXhv.js +1 -0
  20. package/dist/web/assets/index-Devf7glZ.css +1 -0
  21. package/dist/web/assets/index-DwUDhQKc.js +6 -0
  22. package/dist/web/assets/model-DVT8sjy9.js +1 -0
  23. package/dist/web/assets/paths-B790iniO.js +1 -0
  24. package/dist/web/assets/runtime-dom.esm-bundler-DttsK21F.js +1 -0
  25. package/dist/web/assets/vue-router-BYTAoWT3.js +1 -0
  26. package/dist/web/index.html +22 -0
  27. package/graph-provider/Dockerfile +16 -0
  28. package/graph-provider/fuli_graph/__init__.py +5 -0
  29. package/graph-provider/fuli_graph/app.py +547 -0
  30. package/graph-provider/fuli_graph/auth.py +17 -0
  31. package/graph-provider/fuli_graph/collaboration_context.py +290 -0
  32. package/graph-provider/fuli_graph/config.py +34 -0
  33. package/graph-provider/fuli_graph/graph_models.py +123 -0
  34. package/graph-provider/fuli_graph/graph_projection.py +405 -0
  35. package/graph-provider/fuli_graph/graph_query.py +561 -0
  36. package/graph-provider/fuli_graph/knowledge_audit.py +765 -0
  37. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +246 -0
  38. package/graph-provider/fuli_graph/knowledge_management.py +816 -0
  39. package/graph-provider/fuli_graph/knowledge_records.py +65 -0
  40. package/graph-provider/fuli_graph/knowledge_search.py +723 -0
  41. package/graph-provider/fuli_graph/knowledge_usage_models.py +52 -0
  42. package/graph-provider/fuli_graph/model_validation.py +58 -0
  43. package/graph-provider/fuli_graph/models.py +1179 -0
  44. package/graph-provider/fuli_graph/personal_project_access.py +29 -0
  45. package/graph-provider/fuli_graph/preference_conflicts.py +420 -0
  46. package/graph-provider/fuli_graph/project_action_models.py +142 -0
  47. package/graph-provider/fuli_graph/project_knowledge.py +707 -0
  48. package/graph-provider/fuli_graph/project_management.py +251 -0
  49. package/graph-provider/fuli_graph/provider_values.py +40 -0
  50. package/graph-provider/fuli_graph/runtime.py +350 -0
  51. package/graph-provider/fuli_graph/search_projection.py +156 -0
  52. package/graph-provider/fuli_graph/store.py +24 -0
  53. package/graph-provider/fuli_graph/store_identity.py +237 -0
  54. package/graph-provider/fuli_graph/store_knowledge.py +447 -0
  55. package/graph-provider/fuli_graph/store_projects.py +242 -0
  56. package/graph-provider/fuli_graph/store_publication.py +370 -0
  57. package/graph-provider/fuli_graph/store_records.py +134 -0
  58. package/graph-provider/pyproject.toml +35 -0
  59. package/npm-shrinkwrap.json +6045 -0
  60. package/package.json +99 -0
  61. package/skills/capturing-session-knowledge/SKILL.md +226 -0
  62. package/skills/capturing-session-knowledge/agents/openai.yaml +13 -0
  63. package/skills/grilling-project/SKILL.md +129 -0
  64. package/skills/grilling-project/agents/openai.yaml +4 -0
  65. package/skills/grilling-project/references/project-profile.md +105 -0
  66. package/src/agent/candidate-read-service.js +53 -0
  67. package/src/agent/context-pack-service.js +83 -0
  68. package/src/agent/public-read-policy.js +39 -0
  69. package/src/agent/public-read-service.js +69 -0
  70. package/src/agent/read-projection.js +107 -0
  71. package/src/agent/search-service.js +61 -0
  72. package/src/agent/space-boundary.js +33 -0
  73. package/src/agent-tools/graph-definitions.js +479 -0
  74. package/src/agent-tools/graph-handlers.js +96 -0
  75. package/src/agent-tools/schema.js +37 -0
  76. package/src/agent-tools.js +16 -0
  77. package/src/app/application-error.js +13 -0
  78. package/src/app/bootstrap.js +8 -0
  79. package/src/app/create-application.js +143 -0
  80. package/src/app/state-service.js +38 -0
  81. package/src/candidates.js +78 -0
  82. package/src/classifier.js +63 -0
  83. package/src/cli/command-arguments.js +14 -0
  84. package/src/cli/command-registry.js +78 -0
  85. package/src/cli/commands/candidates.js +17 -0
  86. package/src/cli/commands/ingestion.js +32 -0
  87. package/src/cli/commands/reads.js +50 -0
  88. package/src/cli/commands/spaces.js +20 -0
  89. package/src/cli/invocation.js +47 -0
  90. package/src/cli/local-runtime-command.js +94 -0
  91. package/src/cli/local-runtime-options.js +64 -0
  92. package/src/cli/migrate-command.js +71 -0
  93. package/src/cli/migration-destination.js +117 -0
  94. package/src/cli/setup-agent-selection.js +195 -0
  95. package/src/cli/setup-command.js +85 -0
  96. package/src/cli/uninstall-command.js +67 -0
  97. package/src/cli.js +90 -0
  98. package/src/context-pack.js +206 -0
  99. package/src/defaults.js +1 -0
  100. package/src/extractor.js +77 -0
  101. package/src/fact-history.js +39 -0
  102. package/src/facts/fact-service.js +89 -0
  103. package/src/graphiti/agent-access-policy.js +88 -0
  104. package/src/graphiti/capture-policy.js +84 -0
  105. package/src/graphiti/federated-application.js +1292 -0
  106. package/src/graphiti/personal-context.js +29 -0
  107. package/src/graphiti/project-path-context.js +159 -0
  108. package/src/graphiti/provider-client.js +288 -0
  109. package/src/graphiti/runtime-config.js +85 -0
  110. package/src/graphiti/setup.js +43 -0
  111. package/src/graphiti/source-marker.js +142 -0
  112. package/src/http/api-router.js +133 -0
  113. package/src/http/bounded-json-array.js +19 -0
  114. package/src/http/error-mapping.js +36 -0
  115. package/src/http/graph-api-router.js +257 -0
  116. package/src/http/lens-route.js +50 -0
  117. package/src/http/request-policy.js +41 -0
  118. package/src/http/response.js +29 -0
  119. package/src/http/static-handler.js +55 -0
  120. package/src/ingestion.js +161 -0
  121. package/src/lens/lens-correction-service.js +81 -0
  122. package/src/lens/lens-fact-writer.js +85 -0
  123. package/src/lens/lens-input.js +62 -0
  124. package/src/lens/lens-query-projection.js +87 -0
  125. package/src/lens/lens-query-ranking.js +62 -0
  126. package/src/lens/lens-query.js +143 -0
  127. package/src/lens/lens-resource-projection.js +93 -0
  128. package/src/lens/lens-resource-selection.js +45 -0
  129. package/src/lens/lens-resource-service.js +103 -0
  130. package/src/lens/lens-search-query.js +122 -0
  131. package/src/lens/lens-service.js +183 -0
  132. package/src/local-runtime/lifecycle.js +254 -0
  133. package/src/mcp/create-mcp-server.js +85 -0
  134. package/src/mcp/instructions.js +10 -0
  135. package/src/mcp/project-action-preview-tokens.js +72 -0
  136. package/src/mcp/runtime.js +71 -0
  137. package/src/mcp/tool-annotations.js +63 -0
  138. package/src/mcp/tool-result.js +167 -0
  139. package/src/mcp/tool-schema.js +60 -0
  140. package/src/mcp-server.js +76 -0
  141. package/src/models.js +44 -0
  142. package/src/observer.js +83 -0
  143. package/src/package-metadata.js +6 -0
  144. package/src/project-rules.js +33 -0
  145. package/src/publication/canonical-json.js +39 -0
  146. package/src/publication/outbox-service.js +46 -0
  147. package/src/publication/publication-envelope-schema.js +75 -0
  148. package/src/publication/publication-envelope.js +37 -0
  149. package/src/publication/publication-policy.js +82 -0
  150. package/src/publication/publication-service.js +48 -0
  151. package/src/router.js +58 -0
  152. package/src/runtime-options.js +139 -0
  153. package/src/security/episode-visibility.js +14 -0
  154. package/src/security/fact-visibility.js +15 -0
  155. package/src/security/sensitive-content.js +18 -0
  156. package/src/security/source-metadata-policy.js +8 -0
  157. package/src/server/application-lifecycle.js +34 -0
  158. package/src/server/listen.js +24 -0
  159. package/src/server.js +135 -0
  160. package/src/setup/agent-installation-status.js +97 -0
  161. package/src/setup/agents.js +123 -0
  162. package/src/setup/codex-bootstrap.js +118 -0
  163. package/src/setup/codex-config.js +122 -0
  164. package/src/setup/config-backup.js +22 -0
  165. package/src/setup/container-runtime.js +388 -0
  166. package/src/setup/cursor-config.js +61 -0
  167. package/src/setup/graph-runtime.js +360 -0
  168. package/src/setup/node-runtime.js +24 -0
  169. package/src/setup/options.js +91 -0
  170. package/src/setup/paths.js +47 -0
  171. package/src/setup/session-skill.js +91 -0
  172. package/src/setup/setup.js +85 -0
  173. package/src/setup/uninstall.js +90 -0
  174. package/src/storage/episode-evidence-preview.js +100 -0
  175. package/src/storage/fact-search-order.js +18 -0
  176. package/src/storage/file/episode-evidence.js +37 -0
  177. package/src/storage/file/episode-index.js +59 -0
  178. package/src/storage/file/fact-search.js +60 -0
  179. package/src/storage/file-store.js +437 -0
  180. package/src/storage/import-json.js +70 -0
  181. package/src/storage/json-file.js +41 -0
  182. package/src/storage/legacy-record-validation.js +152 -0
  183. package/src/storage/legacy-snapshot-validation.js +32 -0
  184. package/src/storage/migrate.js +128 -0
  185. package/src/storage/migrations/001-initial.sql +83 -0
  186. package/src/storage/migrations/002-storage-integrity.sql +113 -0
  187. package/src/storage/record-validation.js +46 -0
  188. package/src/storage/snapshot-defaults.js +54 -0
  189. package/src/storage/sqlite/candidates-repository.js +84 -0
  190. package/src/storage/sqlite/episode-evidence-repository.js +154 -0
  191. package/src/storage/sqlite/episode-privacy-sql.js +33 -0
  192. package/src/storage/sqlite/episodes-repository.js +52 -0
  193. package/src/storage/sqlite/fact-search-repository.js +162 -0
  194. package/src/storage/sqlite/facts-repository.js +146 -0
  195. package/src/storage/sqlite/imports-repository.js +39 -0
  196. package/src/storage/sqlite/mapper.js +84 -0
  197. package/src/storage/sqlite/open-database.js +15 -0
  198. package/src/storage/sqlite/outbox-repository.js +103 -0
  199. package/src/storage/sqlite/snapshot-repository.js +69 -0
  200. package/src/storage/sqlite/spaces-repository.js +52 -0
  201. package/src/storage/sqlite/subscriptions-repository.js +54 -0
  202. package/src/storage/sqlite-store.js +159 -0
  203. package/src/storage/stable-json.js +24 -0
  204. package/src/storage/store-port.js +20 -0
  205. package/src/store.js +2 -0
  206. package/src/workspace-protocol/array-validation.js +19 -0
  207. package/src/workspace-protocol/base64url-contract.js +27 -0
  208. package/src/workspace-protocol/constants.js +37 -0
  209. package/src/workspace-protocol/error-contract.js +24 -0
  210. package/src/workspace-protocol/fact-contract.js +110 -0
  211. package/src/workspace-protocol/governance-contract.js +47 -0
  212. package/src/workspace-protocol/index.js +53 -0
  213. package/src/workspace-protocol/json-budget.js +19 -0
  214. package/src/workspace-protocol/manifest-contract.js +186 -0
  215. package/src/workspace-protocol/publication-contract.js +212 -0
  216. package/src/workspace-protocol/query-contract.js +149 -0
  217. package/src/workspace-protocol/scalar-contract.js +192 -0
  218. package/src/workspace-protocol/sync-contract.js +184 -0
  219. package/src/workspace-protocol/text-contract.js +39 -0
  220. package/src/workspace-protocol/validation.js +66 -0
@@ -0,0 +1,447 @@
1
+ import asyncio
2
+ import json
3
+
4
+ from fastapi import HTTPException
5
+
6
+ from .collaboration_context import read_collaboration_context
7
+ from .graph_models import GraphResult
8
+ from .graph_query import read_graph
9
+ from .knowledge_search import search_knowledge
10
+ from .models import (
11
+ CollaborationContextResult,
12
+ CommitResult,
13
+ KnowledgeCommit,
14
+ SearchRequest,
15
+ SearchResult,
16
+ StructuredEpisode,
17
+ )
18
+ from .personal_project_access import authorize_personal_project
19
+ from .provider_values import now_utc, stable_uuid
20
+
21
+
22
+ class StoreKnowledge:
23
+ async def commit_personal(self, actor: dict, request: KnowledgeCommit) -> CommitResult:
24
+ self._require_personal()
25
+ space = await self.authorize(actor, request.space_id, 'maintainer')
26
+ if space['kind'] != 'personal':
27
+ raise HTTPException(status_code=422, detail='direct commits are personal-only')
28
+ if request.personal_project_id:
29
+ await authorize_personal_project(
30
+ self, actor, space, request.personal_project_id
31
+ )
32
+ return await self._commit_episode(
33
+ space,
34
+ request.episode,
35
+ personal_project_id=request.personal_project_id,
36
+ )
37
+
38
+ async def search(self, actor: dict, request: SearchRequest) -> SearchResult:
39
+ return await search_knowledge(self, actor, request)
40
+
41
+ async def graph(
42
+ self,
43
+ actor: dict,
44
+ space_id: str,
45
+ limit: int | None = None,
46
+ personal_project_id: str | None = None,
47
+ ) -> GraphResult:
48
+ return await read_graph(
49
+ self, actor, space_id, limit, personal_project_id
50
+ )
51
+
52
+ async def collaboration_context(
53
+ self,
54
+ actor: dict,
55
+ space_id: str,
56
+ personal_project_id: str | None = None,
57
+ limit: int = 100,
58
+ ) -> CollaborationContextResult:
59
+ return await read_collaboration_context(
60
+ self,
61
+ actor,
62
+ space_id,
63
+ personal_project_id,
64
+ limit,
65
+ )
66
+
67
+ async def _commit_episode(
68
+ self,
69
+ space: dict,
70
+ episode: StructuredEpisode,
71
+ *,
72
+ personal_project_id: str | None = None,
73
+ ) -> CommitResult:
74
+ group_id = space['group_id']
75
+ lock = self._group_locks.setdefault(group_id, asyncio.Lock())
76
+ async with lock:
77
+ episode_id = stable_uuid(group_id, 'episode', episode.idempotency_key)
78
+ existing, _, _ = await self.runtime.driver.execute_query(
79
+ 'MATCH (e:Episodic {uuid: $uuid}) RETURN e.uuid AS uuid',
80
+ uuid=episode_id,
81
+ routing_='r',
82
+ )
83
+ entity_ids = [
84
+ _entity_id(
85
+ group_id,
86
+ space['kind'],
87
+ personal_project_id,
88
+ entity.key,
89
+ )
90
+ for entity in episode.entities
91
+ ]
92
+ relationship_ids = [
93
+ _relationship_id(
94
+ group_id,
95
+ space['kind'],
96
+ personal_project_id,
97
+ relationship.key,
98
+ relationship.fact,
99
+ (relationship.valid_at or episode.reference_time).isoformat(),
100
+ )
101
+ for relationship in episode.relationships
102
+ ]
103
+ if existing:
104
+ return CommitResult(
105
+ status='duplicate',
106
+ space_id=space['id'],
107
+ episode_id=episode_id,
108
+ entity_ids=entity_ids,
109
+ relationship_ids=relationship_ids,
110
+ )
111
+
112
+ entity_vectors = await self.runtime.embedder.create_batch(
113
+ [entity.name for entity in episode.entities]
114
+ )
115
+ fact_vectors = await self.runtime.embedder.create_batch(
116
+ [relationship.fact for relationship in episode.relationships]
117
+ )
118
+ entity_rows = []
119
+ entity_by_key = {}
120
+ for entity, entity_id, vector in zip(
121
+ episode.entities, entity_ids, entity_vectors, strict=True
122
+ ):
123
+ entity_by_key[entity.key] = entity_id
124
+ entity_rows.append(
125
+ {
126
+ 'uuid': entity_id,
127
+ 'key': entity.key,
128
+ 'name': entity.name,
129
+ 'type': entity.type,
130
+ 'summary': entity.summary,
131
+ 'origin_quadrant': entity.origin_quadrant,
132
+ 'current_quadrant': entity.current_quadrant,
133
+ 'epistemic_status': entity.epistemic_status,
134
+ 'confirmation_status': entity.confirmation_status,
135
+ 'utility_score': 0.0,
136
+ 'confidence_score': _initial_confidence(
137
+ entity.confirmation_status
138
+ ),
139
+ 'confirmation_basis_json': json.dumps(
140
+ entity.confirmation_basis.model_dump(mode='json'),
141
+ ensure_ascii=False,
142
+ sort_keys=True,
143
+ ),
144
+ 'reasoning_summary': entity.reasoning_summary,
145
+ 'profile_aspect': entity.profile_aspect,
146
+ 'inheritance_mode': entity.inheritance_mode,
147
+ 'inherited_project_ids': entity.inherited_project_ids,
148
+ 'preference_scope': (
149
+ 'project' if entity.profile_aspect and personal_project_id
150
+ else 'global' if entity.profile_aspect else None
151
+ ),
152
+ 'preference_project_id': (
153
+ personal_project_id if entity.profile_aspect else None
154
+ ),
155
+ 'attributes_json': json.dumps(
156
+ entity.attributes, ensure_ascii=False, sort_keys=True
157
+ ),
158
+ 'embedding': vector,
159
+ }
160
+ )
161
+ relationship_rows = []
162
+ superseded_relationships: list[dict[str, str]] = []
163
+ for relationship, relationship_id, vector in zip(
164
+ episode.relationships, relationship_ids, fact_vectors, strict=True
165
+ ):
166
+ superseded_relationships.extend(
167
+ {
168
+ 'key': superseded_key,
169
+ 'replacement_id': relationship_id,
170
+ }
171
+ for superseded_key in relationship.supersedes
172
+ )
173
+ relationship_rows.append(
174
+ {
175
+ 'uuid': relationship_id,
176
+ 'key': relationship.key,
177
+ 'source_uuid': entity_by_key[relationship.source],
178
+ 'target_uuid': entity_by_key[relationship.target],
179
+ 'name': relationship.type,
180
+ 'fact': relationship.fact,
181
+ 'embedding': vector,
182
+ 'valid_at': relationship.valid_at or episode.reference_time,
183
+ 'invalid_at': relationship.invalid_at,
184
+ 'confidence': relationship.confidence,
185
+ 'origin_quadrant': relationship.origin_quadrant,
186
+ 'current_quadrant': relationship.current_quadrant,
187
+ 'epistemic_status': relationship.epistemic_status,
188
+ 'confirmation_status': relationship.confirmation_status,
189
+ 'utility_score': 0.0,
190
+ 'confidence_score': _initial_confidence(
191
+ relationship.confirmation_status
192
+ ),
193
+ 'confirmation_basis_json': json.dumps(
194
+ relationship.confirmation_basis.model_dump(mode='json'),
195
+ ensure_ascii=False,
196
+ sort_keys=True,
197
+ ),
198
+ 'reasoning_summary': relationship.reasoning_summary,
199
+ 'profile_aspect': relationship.profile_aspect,
200
+ 'inheritance_mode': relationship.inheritance_mode,
201
+ 'inherited_project_ids': relationship.inherited_project_ids,
202
+ 'preference_scope': (
203
+ 'project' if relationship.profile_aspect and personal_project_id
204
+ else 'global' if relationship.profile_aspect else None
205
+ ),
206
+ 'preference_project_id': (
207
+ personal_project_id if relationship.profile_aspect else None
208
+ ),
209
+ 'attributes_json': json.dumps(
210
+ relationship.attributes, ensure_ascii=False, sort_keys=True
211
+ ),
212
+ }
213
+ )
214
+
215
+ created_at = now_utc()
216
+ await self.runtime.driver.execute_query(
217
+ '''
218
+ UNWIND $entities AS row
219
+ MERGE (entity:Entity {uuid: row.uuid})
220
+ ON CREATE SET entity.group_id = $group_id,
221
+ entity.name = row.name,
222
+ entity.name_embedding = row.embedding,
223
+ entity.summary = row.summary,
224
+ entity.created_at = coalesce(entity.created_at, $created_at),
225
+ entity.fuli_key = row.key,
226
+ entity.fuli_type = row.type,
227
+ entity.fuli_origin_quadrant = coalesce(
228
+ entity.fuli_origin_quadrant, row.origin_quadrant
229
+ ),
230
+ entity.fuli_current_quadrant = row.current_quadrant,
231
+ entity.fuli_epistemic_status = row.epistemic_status,
232
+ entity.fuli_confirmation_status = row.confirmation_status,
233
+ entity.fuli_confirmation_basis_json = row.confirmation_basis_json,
234
+ entity.fuli_utility_score = coalesce(
235
+ entity.fuli_utility_score, row.utility_score
236
+ ),
237
+ entity.fuli_confidence_score = coalesce(
238
+ entity.fuli_confidence_score, row.confidence_score
239
+ ),
240
+ entity.fuli_qualified_use_count = coalesce(
241
+ entity.fuli_qualified_use_count, 0
242
+ ),
243
+ entity.fuli_distinct_task_count = coalesce(
244
+ entity.fuli_distinct_task_count, 0
245
+ ),
246
+ entity.fuli_usage_generation = coalesce(
247
+ entity.fuli_usage_generation, 1
248
+ ),
249
+ entity.fuli_reasoning_summary = coalesce(
250
+ row.reasoning_summary, entity.fuli_reasoning_summary
251
+ ),
252
+ entity.fuli_profile_aspect = row.profile_aspect,
253
+ entity.fuli_inheritance_mode = row.inheritance_mode,
254
+ entity.fuli_inherited_project_ids = row.inherited_project_ids,
255
+ entity.fuli_preference_scope = row.preference_scope,
256
+ entity.fuli_preference_project_id = row.preference_project_id,
257
+ entity.fuli_attributes_json = row.attributes_json
258
+ WITH count(entity) AS entity_count
259
+ CREATE (episode:Episodic {
260
+ uuid: $episode_id,
261
+ group_id: $group_id,
262
+ name: $episode_name,
263
+ source: 'json',
264
+ source_description: $source_description,
265
+ content: '',
266
+ valid_at: $reference_time,
267
+ created_at: $created_at,
268
+ entity_edges: $relationship_ids,
269
+ fuli_session_id: $session_id,
270
+ fuli_source_kind: $source_kind,
271
+ fuli_source_application: $source_application,
272
+ fuli_source_turn_id: $source_turn_id,
273
+ fuli_source_excerpt: $source_excerpt,
274
+ fuli_summary: $summary,
275
+ fuli_sensitivity: $sensitivity,
276
+ fuli_personal_project_id: $personal_project_id,
277
+ fuli_idempotency_key: $idempotency_key
278
+ })
279
+ WITH episode
280
+ UNWIND $entities AS row
281
+ MATCH (entity:Entity {uuid: row.uuid})
282
+ MERGE (episode)-[mention:MENTIONS {uuid: row.mention_uuid}]->(entity)
283
+ SET mention.group_id = $group_id,
284
+ mention.created_at = $created_at
285
+ WITH episode
286
+ UNWIND $relationships AS row
287
+ MATCH (source:Entity {uuid: row.source_uuid})
288
+ MATCH (target:Entity {uuid: row.target_uuid})
289
+ MERGE (source)-[edge:RELATES_TO {uuid: row.uuid}]->(target)
290
+ ON CREATE SET edge.group_id = $group_id,
291
+ edge.name = row.name,
292
+ edge.fact = row.fact,
293
+ edge.fact_embedding = row.embedding,
294
+ edge.episodes = [$episode_id],
295
+ edge.created_at = $created_at,
296
+ edge.valid_at = row.valid_at,
297
+ edge.invalid_at = row.invalid_at,
298
+ edge.reference_time = $reference_time,
299
+ edge.fuli_key = row.key,
300
+ edge.fuli_confidence = row.confidence,
301
+ edge.fuli_origin_quadrant = row.origin_quadrant,
302
+ edge.fuli_current_quadrant = row.current_quadrant,
303
+ edge.fuli_epistemic_status = row.epistemic_status,
304
+ edge.fuli_confirmation_status = row.confirmation_status,
305
+ edge.fuli_confirmation_basis_json = row.confirmation_basis_json,
306
+ edge.fuli_utility_score = row.utility_score,
307
+ edge.fuli_confidence_score = row.confidence_score,
308
+ edge.fuli_qualified_use_count = 0,
309
+ edge.fuli_distinct_task_count = 0,
310
+ edge.fuli_usage_generation = 1,
311
+ edge.fuli_reasoning_summary = row.reasoning_summary,
312
+ edge.fuli_profile_aspect = row.profile_aspect,
313
+ edge.fuli_inheritance_mode = row.inheritance_mode,
314
+ edge.fuli_inherited_project_ids = row.inherited_project_ids,
315
+ edge.fuli_preference_scope = row.preference_scope,
316
+ edge.fuli_preference_project_id = row.preference_project_id,
317
+ edge.fuli_attributes_json = row.attributes_json
318
+ SET edge.episodes =
319
+ CASE WHEN $episode_id IN coalesce(edge.episodes, [])
320
+ THEN edge.episodes
321
+ ELSE coalesce(edge.episodes, []) + $episode_id END
322
+ WITH count(edge) AS edge_count
323
+ OPTIONAL MATCH ()-[old:RELATES_TO {group_id: $group_id}]->()
324
+ WHERE old.invalid_at IS NULL
325
+ AND NOT (old.uuid IN $relationship_ids)
326
+ AND (
327
+ EXISTS {
328
+ MATCH (:FuliSpace {id: $space_id})-
329
+ [:HAS_KNOWLEDGE_ASSIGNMENT]->
330
+ (assignment:FuliKnowledgeAssignment {
331
+ item_kind: 'relationship',
332
+ item_id: old.uuid
333
+ })
334
+ WHERE assignment.project_id = $personal_project_id
335
+ }
336
+ OR (
337
+ NOT EXISTS {
338
+ MATCH (:FuliSpace {id: $space_id})-
339
+ [:HAS_KNOWLEDGE_ASSIGNMENT]->
340
+ (:FuliKnowledgeAssignment {
341
+ item_kind: 'relationship',
342
+ item_id: old.uuid
343
+ })
344
+ }
345
+ AND EXISTS {
346
+ MATCH (old_episode:Episodic {group_id: $group_id})
347
+ WHERE old_episode.uuid IN coalesce(old.episodes, [])
348
+ AND (
349
+ old_episode.fuli_personal_project_id =
350
+ $personal_project_id
351
+ OR (
352
+ old_episode.fuli_personal_project_id IS NULL
353
+ AND $personal_project_id IS NULL
354
+ )
355
+ )
356
+ }
357
+ )
358
+ )
359
+ AND any(
360
+ replacement IN $superseded_relationships
361
+ WHERE replacement.key = old.fuli_key
362
+ )
363
+ WITH edge_count, old, head([
364
+ replacement IN $superseded_relationships
365
+ WHERE replacement.key = old.fuli_key | replacement
366
+ ]) AS replacement
367
+ FOREACH (_ IN CASE WHEN old IS NULL THEN [] ELSE [1] END |
368
+ SET old.invalid_at = $reference_time,
369
+ old.expired_at = $created_at,
370
+ old.fuli_replaced_by_item_id = replacement.replacement_id,
371
+ old.fuli_replaced_by_item_kind = 'relationship'
372
+ )
373
+ RETURN edge_count
374
+ ''',
375
+ entities=[
376
+ {
377
+ **row,
378
+ 'mention_uuid': stable_uuid(episode_id, 'mentions', row['uuid']),
379
+ }
380
+ for row in entity_rows
381
+ ],
382
+ relationships=relationship_rows,
383
+ superseded_relationships=superseded_relationships,
384
+ space_id=space['id'],
385
+ group_id=group_id,
386
+ episode_id=episode_id,
387
+ episode_name=episode.name,
388
+ source_description=episode.source_description,
389
+ reference_time=episode.reference_time,
390
+ created_at=created_at,
391
+ relationship_ids=relationship_ids,
392
+ session_id=episode.session_id,
393
+ source_kind=episode.source_kind,
394
+ source_application=episode.source_application,
395
+ source_turn_id=episode.source_turn_id,
396
+ source_excerpt=episode.source_excerpt,
397
+ summary=episode.summary,
398
+ sensitivity=episode.sensitivity,
399
+ personal_project_id=personal_project_id,
400
+ idempotency_key=episode.idempotency_key,
401
+ )
402
+ return CommitResult(
403
+ status='committed',
404
+ space_id=space['id'],
405
+ episode_id=episode_id,
406
+ entity_ids=entity_ids,
407
+ relationship_ids=relationship_ids,
408
+ )
409
+
410
+
411
+ def _initial_confidence(status: str) -> float:
412
+ return {
413
+ 'pending': 0.5,
414
+ 'agent_confirmed': 0.75,
415
+ 'confirmed': 1.0,
416
+ }[status]
417
+
418
+
419
+ def _entity_id(
420
+ group_id: str,
421
+ space_kind: str,
422
+ personal_project_id: str | None,
423
+ key: str,
424
+ ) -> str:
425
+ if space_kind == 'personal' and personal_project_id:
426
+ return stable_uuid(
427
+ group_id,
428
+ 'entity',
429
+ 'personal-project',
430
+ personal_project_id,
431
+ key,
432
+ )
433
+ return stable_uuid(group_id, 'entity', key)
434
+
435
+
436
+ def _relationship_id(
437
+ group_id: str,
438
+ space_kind: str,
439
+ personal_project_id: str | None,
440
+ key: str,
441
+ fact: str,
442
+ reference_time: str,
443
+ ) -> str:
444
+ parts = [group_id, 'relationship']
445
+ if space_kind == 'personal' and personal_project_id:
446
+ parts.extend(['personal-project', personal_project_id])
447
+ return stable_uuid(*parts, key, fact, reference_time)
@@ -0,0 +1,242 @@
1
+ from fastapi import HTTPException
2
+
3
+ from .models import (
4
+ PersonalProjectRecord,
5
+ PersonalProjectUpsert,
6
+ ProjectDeleteResult,
7
+ ProjectReleaseRecord,
8
+ ProjectRelationCreate,
9
+ ProjectRelationDecision,
10
+ ProjectRelationRecord,
11
+ )
12
+ from .project_management import (
13
+ delete_project as delete_public_project,
14
+ list_project_releases as read_project_releases,
15
+ )
16
+ from .provider_values import now_utc, stable_uuid
17
+
18
+
19
+ class StoreProjects:
20
+ async def list_project_releases(
21
+ self,
22
+ actor: dict,
23
+ project_id: str,
24
+ ) -> list[ProjectReleaseRecord]:
25
+ self._require_workspace()
26
+ return await read_project_releases(self, actor, project_id)
27
+
28
+ async def delete_project(
29
+ self,
30
+ actor: dict,
31
+ project_id: str,
32
+ ) -> ProjectDeleteResult:
33
+ return await delete_public_project(self, actor, project_id)
34
+
35
+ async def upsert_personal_project(
36
+ self,
37
+ actor: dict,
38
+ request: PersonalProjectUpsert,
39
+ ) -> PersonalProjectRecord:
40
+ self._require_personal()
41
+ await self.authorize(actor, request.personal_space_id, 'maintainer')
42
+ node_id = stable_uuid(
43
+ self.settings.provider_id,
44
+ request.personal_space_id,
45
+ 'personal-project',
46
+ request.project_id,
47
+ )
48
+ publication_key = stable_uuid(node_id, 'publication')
49
+ updated_at = now_utc()
50
+ records, _, _ = await self.runtime.driver.execute_query(
51
+ '''
52
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
53
+ MERGE (project:FuliPersonalProject {id: $id})
54
+ ON CREATE SET project.project_id = $project_id,
55
+ project.publication_key = $publication_key,
56
+ project.created_at = $updated_at
57
+ SET project.profile_json = $profile_json,
58
+ project.name = $name,
59
+ project.updated_at = $updated_at
60
+ MERGE (space)-[:CONTAINS_PROJECT]->(project)
61
+ RETURN project
62
+ ''',
63
+ personal_space_id=request.personal_space_id,
64
+ id=node_id,
65
+ project_id=request.project_id,
66
+ publication_key=publication_key,
67
+ profile_json=request.profile.model_dump_json(),
68
+ name=request.profile.name,
69
+ updated_at=updated_at,
70
+ )
71
+ return self._personal_project(records[0]['project'], request.personal_space_id)
72
+
73
+ async def list_personal_projects(
74
+ self,
75
+ actor: dict,
76
+ personal_space_id: str,
77
+ ) -> list[PersonalProjectRecord]:
78
+ self._require_personal()
79
+ await self.authorize(actor, personal_space_id, 'reader')
80
+ records, _, _ = await self.runtime.driver.execute_query(
81
+ '''
82
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
83
+ [:CONTAINS_PROJECT]->(project:FuliPersonalProject)
84
+ RETURN project ORDER BY project.updated_at DESC
85
+ ''',
86
+ personal_space_id=personal_space_id,
87
+ routing_='r',
88
+ )
89
+ return [
90
+ self._personal_project(record['project'], personal_space_id)
91
+ for record in records
92
+ ]
93
+
94
+ async def get_personal_project(
95
+ self,
96
+ actor: dict,
97
+ personal_space_id: str,
98
+ project_id: str,
99
+ ) -> PersonalProjectRecord:
100
+ self._require_personal()
101
+ await self.authorize(actor, personal_space_id, 'reader')
102
+ records, _, _ = await self.runtime.driver.execute_query(
103
+ '''
104
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
105
+ [:CONTAINS_PROJECT]->
106
+ (project:FuliPersonalProject {project_id: $project_id})
107
+ RETURN project
108
+ ''',
109
+ personal_space_id=personal_space_id,
110
+ project_id=project_id,
111
+ routing_='r',
112
+ )
113
+ if not records:
114
+ raise HTTPException(status_code=404, detail='personal project not found')
115
+ return self._personal_project(records[0]['project'], personal_space_id)
116
+
117
+ async def create_project_relation(
118
+ self,
119
+ actor: dict,
120
+ source_project_id: str,
121
+ request: ProjectRelationCreate,
122
+ ) -> ProjectRelationRecord:
123
+ self._require_workspace()
124
+ if source_project_id == request.target_project_id:
125
+ raise HTTPException(status_code=422, detail='a project cannot relate to itself')
126
+ await self.authorize(actor, source_project_id, 'maintainer')
127
+ target, _, _ = await self.runtime.driver.execute_query(
128
+ '''
129
+ MATCH (target:FuliSpace {
130
+ id: $target_project_id,
131
+ kind: 'project',
132
+ visibility: 'public'
133
+ })
134
+ RETURN target
135
+ ''',
136
+ target_project_id=request.target_project_id,
137
+ routing_='r',
138
+ )
139
+ if not target:
140
+ raise HTTPException(status_code=404, detail='target project not found')
141
+ if request.relation_type == 'PART_OF':
142
+ existing_parent, _, _ = await self.runtime.driver.execute_query(
143
+ '''
144
+ MATCH (:FuliSpace {id: $source_project_id})-
145
+ [relation:FULI_PROJECT_RELATION {relation_type: 'PART_OF'}]->
146
+ (:FuliSpace)
147
+ WHERE relation.status IN ['pending', 'active']
148
+ RETURN relation.id AS id
149
+ ''',
150
+ source_project_id=source_project_id,
151
+ routing_='r',
152
+ )
153
+ if existing_parent:
154
+ raise HTTPException(status_code=409, detail='project already has a parent relation')
155
+ relation_id = stable_uuid(
156
+ self.settings.provider_id,
157
+ 'project-relation',
158
+ source_project_id,
159
+ request.relation_type,
160
+ request.target_project_id,
161
+ )
162
+ relation_status = 'pending' if request.relation_type == 'PART_OF' else 'active'
163
+ created_at = now_utc()
164
+ records, _, _ = await self.runtime.driver.execute_query(
165
+ '''
166
+ MATCH (source:FuliSpace {id: $source_project_id, kind: 'project'})
167
+ MATCH (target:FuliSpace {id: $target_project_id, kind: 'project'})
168
+ MERGE (source)-[relation:FULI_PROJECT_RELATION {id: $id}]->(target)
169
+ ON CREATE SET relation.source_project_id = $source_project_id,
170
+ relation.target_project_id = $target_project_id,
171
+ relation.relation_type = $relation_type,
172
+ relation.status = $relation_status,
173
+ relation.note = $note,
174
+ relation.created_by = $created_by,
175
+ relation.created_at = $created_at
176
+ RETURN relation
177
+ ''',
178
+ source_project_id=source_project_id,
179
+ target_project_id=request.target_project_id,
180
+ id=relation_id,
181
+ relation_type=request.relation_type,
182
+ relation_status=relation_status,
183
+ note=request.note,
184
+ created_by=actor['id'],
185
+ created_at=created_at,
186
+ )
187
+ return self._project_relation(records[0]['relation'])
188
+
189
+ async def list_project_relations(
190
+ self,
191
+ actor: dict,
192
+ project_id: str,
193
+ ) -> list[ProjectRelationRecord]:
194
+ self._require_workspace()
195
+ await self.authorize(actor, project_id, 'reader')
196
+ records, _, _ = await self.runtime.driver.execute_query(
197
+ '''
198
+ MATCH (source:FuliSpace)-[relation:FULI_PROJECT_RELATION]->
199
+ (target:FuliSpace)
200
+ WHERE source.id = $project_id OR target.id = $project_id
201
+ RETURN relation ORDER BY relation.created_at DESC
202
+ ''',
203
+ project_id=project_id,
204
+ routing_='r',
205
+ )
206
+ return [self._project_relation(record['relation']) for record in records]
207
+
208
+ async def decide_project_relation(
209
+ self,
210
+ actor: dict,
211
+ target_project_id: str,
212
+ relation_id: str,
213
+ decision: ProjectRelationDecision,
214
+ ) -> ProjectRelationRecord:
215
+ self._require_workspace()
216
+ await self.authorize(actor, target_project_id, 'maintainer')
217
+ decided_at = now_utc()
218
+ relation_status = 'active' if decision.decision == 'confirm' else 'rejected'
219
+ records, _, _ = await self.runtime.driver.execute_query(
220
+ '''
221
+ MATCH (:FuliSpace)-[relation:FULI_PROJECT_RELATION {
222
+ id: $relation_id,
223
+ target_project_id: $target_project_id,
224
+ relation_type: 'PART_OF',
225
+ status: 'pending'
226
+ }]->(:FuliSpace)
227
+ SET relation.status = $relation_status,
228
+ relation.decision_note = $note,
229
+ relation.decided_by = $decided_by,
230
+ relation.decided_at = $decided_at
231
+ RETURN relation
232
+ ''',
233
+ relation_id=relation_id,
234
+ target_project_id=target_project_id,
235
+ relation_status=relation_status,
236
+ note=decision.note,
237
+ decided_by=actor['id'],
238
+ decided_at=decided_at,
239
+ )
240
+ if not records:
241
+ raise HTTPException(status_code=409, detail='project relation is not pending')
242
+ return self._project_relation(records[0]['relation'])