fuli-context 0.7.5 → 0.7.7

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 (148) hide show
  1. package/README.md +69 -13
  2. package/README.zh-CN.md +60 -14
  3. package/dist/web/assets/{AboutPage-ByCUor-j.js → AboutPage-BcZeFcHe.js} +1 -1
  4. package/dist/web/assets/{ConnectionsPage-DkdkZ40Q.js → ConnectionsPage-xAQYl1SQ.js} +1 -1
  5. package/dist/web/assets/EmployeeRecruitDialog-CbPAmhy7.js +1 -0
  6. package/dist/web/assets/EmployeeRecruitDialog-CfFlyKSh.css +1 -0
  7. package/dist/web/assets/EmployeeWorkbenchPage-BQvTLoc8.css +1 -0
  8. package/dist/web/assets/EmployeeWorkbenchPage-DF3EL0Ui.js +1 -0
  9. package/dist/web/assets/KnowledgeEditDialog-UnnBDt0I.js +1 -0
  10. package/dist/web/assets/{KnowledgeEditDialog-DWUV_NPp.css → KnowledgeEditDialog-iv-dgUeD.css} +1 -1
  11. package/dist/web/assets/{KnowledgeInspector-CYAjAb9J.js → KnowledgeInspector-BqPJH5Rr.js} +1 -1
  12. package/dist/web/assets/{KnowledgeOrganizerPage-CV4F54R_.js → KnowledgeOrganizerPage-CyvRflXM.js} +1 -1
  13. package/dist/web/assets/KnowledgePage-59eczTqe.js +1 -0
  14. package/dist/web/assets/{KnowledgeWorkspace-z4u5mXOG.js → KnowledgeWorkspace-CfIjhOog.js} +1 -1
  15. package/dist/web/assets/{OverviewPage-BWOfeS6k.js → OverviewPage-Xyfm5xkZ.js} +1 -1
  16. package/dist/web/assets/{PersonalProfilePage-c3w4-h7a.js → PersonalProfilePage-DB9mByB3.js} +1 -1
  17. package/dist/web/assets/PersonalProjectsPage-_-YXVi5O.js +1 -0
  18. package/dist/web/assets/ProjectAgentsPage-C3vW6wmk.css +1 -0
  19. package/dist/web/assets/ProjectAgentsPage-DeeQByPX.js +11 -0
  20. package/dist/web/assets/{PublicProjectsPage-DdH0oGB3.js → PublicProjectsPage-BEZ1YPs3.js} +1 -1
  21. package/dist/web/assets/{ReviewPage-BYIpcIHG.js → ReviewPage-CQYGwFBH.js} +1 -1
  22. package/dist/web/assets/SearchableSelect-D27H0MoP.js +1 -0
  23. package/dist/web/assets/{SettingsPage-2VkbXgZi.js → SettingsPage-BlEZ03Yi.js} +1 -1
  24. package/dist/web/assets/{WritingTastePage-BOPQZuva.js → WritingTastePage-DvRt_nmP.js} +1 -1
  25. package/dist/web/assets/catalog-DFvIaHVS.js +1 -0
  26. package/dist/web/assets/{console-BlAhujsH.js → console-tOxOl8Vq.js} +1 -1
  27. package/dist/web/assets/conversation-launcher-settings-DuFSpPAo.js +1 -0
  28. package/dist/web/assets/{i18n-Ci3heuiL.js → i18n-CgBHZthI.js} +3 -3
  29. package/dist/web/assets/{identity-4_-v6cB7.js → identity-CFo_Uw1H.js} +1 -1
  30. package/dist/web/assets/index-CJc5ppXH.css +1 -0
  31. package/dist/web/assets/index-yCi3cz2l.js +2 -0
  32. package/dist/web/assets/{model-yG0YqUyZ.js → model-BtnVr7fX.js} +1 -1
  33. package/dist/web/assets/{useApi-CROJJdhE-twhdZArA.js → useApi-CROJJdhE-B6MHm_Av.js} +1 -1
  34. package/dist/web/assets/{vue-router-C-wot_Di.js → vue-router-CzHm-y-x.js} +1 -1
  35. package/dist/web/index.html +7 -6
  36. package/docs/employee-agents.md +99 -0
  37. package/docs/project-agent-memory.md +479 -0
  38. package/graph-provider/fuli_graph/knowledge_audit_models.py +171 -0
  39. package/graph-provider/fuli_graph/model_base.py +5 -0
  40. package/graph-provider/fuli_graph/model_validation.py +32 -0
  41. package/graph-provider/fuli_graph/models.py +26 -170
  42. package/graph-provider/fuli_graph/project_agent_context_models.py +29 -0
  43. package/graph-provider/fuli_graph/project_agent_coordination_models.py +22 -0
  44. package/graph-provider/fuli_graph/project_agent_executor_models.py +35 -7
  45. package/graph-provider/fuli_graph/project_agent_memory_models.py +79 -0
  46. package/graph-provider/fuli_graph/project_agent_memory_routes.py +46 -0
  47. package/graph-provider/fuli_graph/project_agent_models.py +9 -0
  48. package/graph-provider/fuli_graph/project_agent_recruitment_values.py +48 -0
  49. package/graph-provider/fuli_graph/project_agent_routes.py +50 -1
  50. package/graph-provider/fuli_graph/project_agent_task_models.py +220 -29
  51. package/graph-provider/fuli_graph/runtime.py +37 -1
  52. package/graph-provider/fuli_graph/store.py +6 -0
  53. package/graph-provider/fuli_graph/store_project_agent_context.py +65 -0
  54. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +122 -0
  55. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +175 -11
  56. package/graph-provider/fuli_graph/store_project_agent_executor_routing.py +33 -10
  57. package/graph-provider/fuli_graph/store_project_agent_executors.py +181 -14
  58. package/graph-provider/fuli_graph/store_project_agent_memory.py +174 -0
  59. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +423 -0
  60. package/graph-provider/fuli_graph/store_project_agent_task_persistence.py +152 -0
  61. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +187 -21
  62. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +756 -164
  63. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +396 -7
  64. package/graph-provider/fuli_graph/store_project_agent_tasks.py +265 -465
  65. package/graph-provider/fuli_graph/store_project_agents.py +214 -46
  66. package/graph-provider/fuli_graph/store_task_contexts.py +217 -0
  67. package/graph-provider/fuli_graph/store_transactions.py +40 -0
  68. package/graph-provider/fuli_graph/task_context_models.py +50 -0
  69. package/graph-provider/fuli_graph/task_context_routes.py +31 -0
  70. package/graph-provider/fuli_graph/workflow_observation_models.py +1 -1
  71. package/graph-provider/pyproject.toml +1 -1
  72. package/npm-shrinkwrap.json +8 -8
  73. package/package.json +5 -2
  74. package/skills/capturing-session-knowledge/SKILL.md +18 -6
  75. package/src/adaptive-runtime/executor-pool.js +46 -5
  76. package/src/adaptive-runtime/graph-services.js +10 -3
  77. package/src/adaptive-runtime/lease-client.js +66 -14
  78. package/src/adaptive-runtime/runtime-broker.js +24 -9
  79. package/src/agent-tools/employee-definitions.js +43 -0
  80. package/src/agent-tools/graph-definitions.js +48 -6
  81. package/src/agent-tools/graph-handlers.js +15 -0
  82. package/src/agent-tools/project-agent-definitions.js +35 -4
  83. package/src/agent-tools/project-agent-memory-definitions.js +36 -0
  84. package/src/agent-tools.js +6 -2
  85. package/src/agents/codex/lifecycle-hook.js +107 -0
  86. package/src/agents/codex/lifecycle-hooks.js +231 -0
  87. package/src/agents/cursor/lifecycle-hook.js +133 -0
  88. package/src/agents/cursor/lifecycle-hooks.js +50 -0
  89. package/src/agents/lifecycle-message.js +19 -0
  90. package/src/app/agent-request-context.js +23 -0
  91. package/src/cli/command-registry.js +2 -0
  92. package/src/cli/employee-command.js +17 -0
  93. package/src/cli/remote-mcp-command.js +282 -0
  94. package/src/cli/setup-command.js +5 -0
  95. package/src/cli.js +10 -0
  96. package/src/employees/catalog/jefa.json +21 -0
  97. package/src/employees/install-package.js +77 -0
  98. package/src/employees/management-policy.js +88 -0
  99. package/src/employees/manifest.js +53 -0
  100. package/src/employees/package-registry.js +107 -0
  101. package/src/employees/project-selection.js +50 -0
  102. package/src/employees/recruitment.js +199 -0
  103. package/src/employees/service.js +83 -0
  104. package/src/employees/task-entry.js +39 -0
  105. package/src/graphiti/agent-knowledge-workflows.js +135 -25
  106. package/src/graphiti/federated-application.js +78 -35
  107. package/src/graphiti/project-agent-control-plane.js +32 -3
  108. package/src/graphiti/project-agent-mapping.js +175 -4
  109. package/src/graphiti/project-agent-memory.js +115 -0
  110. package/src/graphiti/project-agent-task-entry.js +126 -0
  111. package/src/graphiti/project-agent-workflows.js +49 -14
  112. package/src/graphiti/provider-client.js +78 -4
  113. package/src/http/api-router.js +25 -5
  114. package/src/http/employee-api-router.js +64 -0
  115. package/src/http/error-mapping.js +4 -0
  116. package/src/http/graph-api-router.js +14 -0
  117. package/src/http/request-policy.js +6 -3
  118. package/src/http/static-handler.js +4 -3
  119. package/src/mcp/create-mcp-server.js +98 -37
  120. package/src/mcp/direct-call.js +38 -0
  121. package/src/mcp/instructions.js +8 -7
  122. package/src/mcp/provider-task-context-registry.js +74 -0
  123. package/src/mcp/remote-http-server.js +761 -0
  124. package/src/mcp/session-id.js +29 -3
  125. package/src/mcp/task-context-registry.js +88 -29
  126. package/src/mcp/tool-annotations.js +12 -2
  127. package/src/mcp/tool-result.js +8 -3
  128. package/src/mcp-server.js +20 -8
  129. package/src/native-runtime/runtime.js +58 -8
  130. package/src/server.js +76 -28
  131. package/src/setup/agent-installation-status.js +38 -6
  132. package/src/setup/agents.js +20 -8
  133. package/src/setup/claude-code-config.js +55 -13
  134. package/src/setup/codex-bootstrap.js +11 -10
  135. package/src/setup/codex-config.js +74 -7
  136. package/src/setup/config-backup.js +18 -2
  137. package/src/setup/cursor-config.js +22 -5
  138. package/src/setup/setup.js +30 -22
  139. package/src/storage/json-file.js +11 -3
  140. package/dist/web/assets/KnowledgeEditDialog-DSe3Gwep.js +0 -1
  141. package/dist/web/assets/KnowledgePage-vYYXipOi.js +0 -1
  142. package/dist/web/assets/PersonalProjectsPage-B7AlLEwf.js +0 -1
  143. package/dist/web/assets/ProjectAgentsPage-CgftpIPB.css +0 -1
  144. package/dist/web/assets/ProjectAgentsPage-DTHcL_ox.js +0 -11
  145. package/dist/web/assets/SearchableSelect-Dz13ClQb.js +0 -1
  146. package/dist/web/assets/conversation-launcher-settings-DTYxiLJt.js +0 -1
  147. package/dist/web/assets/index-BnH5mcZx.css +0 -1
  148. package/dist/web/assets/index-C67Q0JY2.js +0 -2
@@ -0,0 +1,217 @@
1
+ """Task lifecycle shared by independent host-hook and MCP processes.
2
+
3
+ Session and token nodes are locked in the same order. End-of-task claims bind
4
+ an immutable payload fingerprint before side effects; retries can safely resume.
5
+ """
6
+
7
+ import json
8
+
9
+ from fastapi import HTTPException
10
+ from neo4j.exceptions import ConstraintError
11
+
12
+ from .personal_project_access import authorize_personal_project
13
+ from .project_agent_access import authorize_project_agent
14
+ from .provider_values import now_utc, stable_uuid
15
+ from .project_agent_memory_models import ProjectAgentMemoryWrite
16
+ from .store_transactions import query_store_transaction
17
+
18
+
19
+ class StoreTaskContexts:
20
+ async def begin_task_context(self, actor, request):
21
+ self._require_personal()
22
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
23
+ if request.personal_project_id:
24
+ await authorize_personal_project(self, actor, space, request.personal_project_id)
25
+ if request.project_agent_id:
26
+ agent = await authorize_project_agent(
27
+ self, actor, space, request.personal_project_id,
28
+ request.project_agent_id, require_active=True,
29
+ )
30
+ allowed_clients = json.loads(agent['profile_json']).get('allowed_clients', [])
31
+ if request.source_application not in allowed_clients:
32
+ raise HTTPException(status_code=403, detail='Agent is not allowed in this client')
33
+ record = request.model_dump(mode='json')
34
+ record.update(checkpoint=None, created_at=now_utc().isoformat())
35
+ try:
36
+ rows, _, _ = await self.runtime.driver.execute_query(
37
+ '''
38
+ MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
39
+ MERGE (session:FuliTaskContextSession {id: $session_id})
40
+ SET session.write_serial = coalesce(session.write_serial, 0) + 1
41
+ MERGE (space)-[:HAS_TASK_CONTEXT_SESSION]->(session)
42
+ WITH session
43
+ OPTIONAL MATCH (session)-[:HAS_CONTEXT]->(previous:FuliTaskContext)
44
+ WHERE previous.token = session.current_token
45
+ WITH session, previous
46
+ OPTIONAL MATCH (session)-[:HAS_CONTEXT]->(retry:FuliTaskContext)
47
+ WHERE retry.token = $token OR ($turn_id IS NOT NULL AND retry.turn_id = $turn_id)
48
+ WITH session, previous, retry
49
+ ORDER BY CASE WHEN retry.token = $token THEN 0 ELSE 1 END, retry.id
50
+ LIMIT 1
51
+ FOREACH (ignored IN CASE WHEN retry IS NULL THEN [1] ELSE [] END |
52
+ CREATE (task:FuliTaskContext {id: $context_id, token: $token,
53
+ turn_id: $turn_id, record_json: $record_json, completed: false,
54
+ personal_space_id: $space_id, personal_project_id: $project_id,
55
+ project_agent_id: $agent_id, created_at: $created_at})
56
+ CREATE (session)-[:HAS_CONTEXT]->(task)
57
+ SET session.current_token = $token
58
+ )
59
+ WITH session, previous, retry
60
+ MATCH (session)-[:HAS_CONTEXT]->(saved:FuliTaskContext)
61
+ WHERE saved.token = coalesce(retry.token, $token)
62
+ RETURN saved.record_json AS record_json,
63
+ saved.token = session.current_token AS is_current,
64
+ previous IS NOT NULL AND NOT coalesce(previous.completed, false)
65
+ AND previous.token <> saved.token AS previous_checkpoint_missing
66
+ ''',
67
+ space_id=request.personal_space_id,
68
+ session_id=self._task_session_id(
69
+ request.personal_space_id, request.source_application, request.session_id,
70
+ ),
71
+ context_id=stable_uuid(request.personal_space_id, request.token),
72
+ token=request.token, turn_id=request.turn_id,
73
+ project_id=request.personal_project_id, agent_id=request.project_agent_id,
74
+ created_at=now_utc(),
75
+ record_json=json.dumps(record, ensure_ascii=False),
76
+ )
77
+ except ConstraintError as error:
78
+ # Tokens are space-wide handles; never attach one to another session.
79
+ raise HTTPException(status_code=409,
80
+ detail='Task context token is already in use') from error
81
+ if not rows[0]['is_current']:
82
+ raise HTTPException(status_code=409, detail='Task turn has been superseded')
83
+ saved = json.loads(rows[0]['record_json'])
84
+ for field in ('personal_project_id', 'project_agent_id', 'source_application', 'turn_id'):
85
+ if saved[field] != getattr(request, field):
86
+ raise HTTPException(status_code=409, detail='Task turn already has another context')
87
+ return {**saved, 'previous_checkpoint_missing': rows[0]['previous_checkpoint_missing']}
88
+
89
+ async def get_task_context(self, actor, space_id, token, source_application):
90
+ self._require_personal()
91
+ await self.authorize(actor, space_id, 'reader')
92
+ rows, _, _ = await self.runtime.driver.execute_query(
93
+ '''
94
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
95
+ [:HAS_TASK_CONTEXT_SESSION]->(session:FuliTaskContextSession)-
96
+ [:HAS_CONTEXT]->(task:FuliTaskContext {token: $token})
97
+ WHERE session.current_token = task.token
98
+ RETURN task.record_json AS record_json
99
+ ''', space_id=space_id, token=token, routing_='r',
100
+ )
101
+ if not rows:
102
+ raise HTTPException(status_code=404, detail='Task context is unknown or superseded')
103
+ record = json.loads(rows[0]['record_json'])
104
+ if record['source_application'] != source_application:
105
+ raise HTTPException(status_code=404, detail='Task context not found for this client')
106
+ return record
107
+
108
+ async def checkpoint_task_context(self, actor, token, request):
109
+ if request.agent_memory is None:
110
+ return await self._checkpoint_task_context(actor, token, request)
111
+ # A rejected preparation cannot commit memory. A later host turn may
112
+ # still supersede a successfully prepared context; complete is separate.
113
+ async with query_store_transaction(self) as scoped:
114
+ record = await scoped._checkpoint_task_context(actor, token, request)
115
+ if not record['project_agent_id'] or not record['personal_project_id']:
116
+ raise HTTPException(status_code=409, detail='Task has no durable Agent memory target')
117
+ memory = await scoped.write_project_agent_memory(actor, ProjectAgentMemoryWrite(
118
+ personal_space_id=request.personal_space_id,
119
+ personal_project_id=record['personal_project_id'],
120
+ agent_id=record['project_agent_id'],
121
+ expected_revision=request.agent_memory.expected_revision,
122
+ memory=request.agent_memory.memory,
123
+ idempotency_key=f'{token}:memory',
124
+ source_application=record['source_application'],
125
+ # The client lifecycle session remains stable when entry and
126
+ # checkpoint hooks use different short-lived MCP processes.
127
+ source_session_id=record['session_id'],
128
+ ))
129
+ return {**record, 'agent_memory': memory.model_dump(mode='json')}
130
+
131
+ async def _checkpoint_task_context(self, actor, token, request):
132
+ await self.authorize(actor, request.personal_space_id, 'maintainer')
133
+ record = await self.get_task_context(
134
+ actor, request.personal_space_id, token, request.source_application,
135
+ )
136
+ checkpoint = request.model_dump(mode='json', exclude={
137
+ 'personal_space_id', 'source_application', 'agent_memory',
138
+ })
139
+ # Complete retries must preserve the original completion timestamp/result.
140
+ record['checkpoint'] = checkpoint
141
+ # Bind memory presence independently of the caller's fingerprint. Its
142
+ # content is already immutable under the memory checkpoint's payload hash.
143
+ # The existing checkpoint lets pre-upgrade claims retry without new writes.
144
+ memory_checkpoint_id = None
145
+ if record['project_agent_id'] and record['personal_project_id']:
146
+ memory_checkpoint_id = stable_uuid(self._agent_memory_id(
147
+ request.personal_space_id, record['personal_project_id'],
148
+ record['project_agent_id'],
149
+ ), f'{token}:memory')
150
+ rows, _, _ = await self.runtime.driver.execute_query(
151
+ '''
152
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
153
+ [:HAS_TASK_CONTEXT_SESSION]->(session:FuliTaskContextSession)-
154
+ [:HAS_CONTEXT]->(task:FuliTaskContext {token: $token})
155
+ SET session.write_serial = coalesce(session.write_serial, 0) + 1
156
+ WITH session, task
157
+ WHERE session.current_token = task.token
158
+ SET task.write_serial = coalesce(task.write_serial, 0) + 1
159
+ WITH task
160
+ OPTIONAL MATCH (memory:FuliProjectAgentMemoryCheckpoint {id: $memory_checkpoint_id})
161
+ WITH task, task.fingerprint IS NULL AS first_claim,
162
+ coalesce(task.agent_memory_claimed, memory IS NOT NULL) AS memory_claimed
163
+ WITH task, first_claim OR (
164
+ task.fingerprint = $fingerprint AND task.disposition = $disposition
165
+ AND task.reason = $reason
166
+ AND (NOT $is_prepare OR memory_claimed = $has_agent_memory)
167
+ ) AS matches,
168
+ CASE WHEN first_claim THEN $has_agent_memory ELSE memory_claimed END AS memory_claimed
169
+ FOREACH (ignored IN CASE WHEN matches AND NOT task.completed THEN [1] ELSE [] END |
170
+ SET task.fingerprint = $fingerprint, task.record_json = $record_json,
171
+ task.agent_memory_claimed = memory_claimed,
172
+ task.completed = $completed, task.disposition = $disposition, task.reason = $reason
173
+ )
174
+ RETURN matches, task.record_json AS record_json
175
+ ''', space_id=request.personal_space_id, token=token,
176
+ fingerprint=request.fingerprint, record_json=json.dumps(record, ensure_ascii=False),
177
+ disposition=request.disposition, reason=request.reason,
178
+ completed=request.phase == 'complete',
179
+ is_prepare=request.phase == 'prepare',
180
+ has_agent_memory=request.agent_memory is not None,
181
+ memory_checkpoint_id=memory_checkpoint_id,
182
+ )
183
+ if not rows or not rows[0]['matches']:
184
+ raise HTTPException(status_code=409, detail='Task checkpoint conflicts or was superseded')
185
+ return json.loads(rows[0]['record_json'])
186
+
187
+ async def verify_task_checkpoint(self, actor, space_id, session_id, source_application):
188
+ self._require_personal()
189
+ await self.authorize(actor, space_id, 'reader')
190
+ rows, _, _ = await self.runtime.driver.execute_query(
191
+ '''
192
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
193
+ [:HAS_TASK_CONTEXT_SESSION]->(session:FuliTaskContextSession {id: $session_id})-
194
+ [:HAS_CONTEXT]->(task:FuliTaskContext)
195
+ WHERE task.token = session.current_token
196
+ RETURN task.record_json AS record_json, task.completed AS completed
197
+ ''', space_id=space_id,
198
+ session_id=self._task_session_id(space_id, source_application, session_id),
199
+ routing_='r',
200
+ )
201
+ if not rows:
202
+ return {'status': 'not_started', 'guidance': 'No Fuli context started; do not block.'}
203
+ record = json.loads(rows[0]['record_json'])
204
+ if rows[0]['completed']:
205
+ return {'status': 'checkpointed', 'disposition': record['checkpoint']['disposition']}
206
+ return {
207
+ 'status': 'checkpoint_required', 'decision': 'block',
208
+ 'task_context_token': record['token'],
209
+ 'reason': f'FULI_CHECKPOINT_REQUIRED: {record["token"]} '
210
+ 'Before finishing, call checkpoint_task_knowledge using this task_context_token '
211
+ 'with capture_candidates or retain_nothing; include agentMemory when durable '
212
+ 'role context changed. Never store raw transcripts or credentials.',
213
+ }
214
+
215
+ def _task_session_id(self, space_id, source_application, session_id):
216
+ return stable_uuid(self.settings.provider_id, space_id, source_application,
217
+ session_id, 'task-context-session')
@@ -0,0 +1,40 @@
1
+ """Explicit transaction scope for query-only Store operations.
2
+
3
+ The scoped Store owns a private driver port. Never replace the shared runtime's
4
+ driver: simultaneous HTTP requests must not borrow one another's transactions.
5
+ """
6
+
7
+ from contextlib import asynccontextmanager
8
+ from copy import copy
9
+ from types import SimpleNamespace
10
+
11
+
12
+ @asynccontextmanager
13
+ async def query_store_transaction(store):
14
+ async with store.runtime.driver.transaction() as transaction:
15
+ scoped = copy(store)
16
+ # Query-only workflows intentionally have no raw Graphiti escape hatch.
17
+ scoped.runtime = SimpleNamespace(driver=TransactionQueryDriver(transaction))
18
+ yield scoped
19
+
20
+
21
+ class TransactionQueryDriver:
22
+ """Adapt the Store execute_query port to one already-open transaction."""
23
+
24
+ def __init__(self, transaction):
25
+ self._transaction = transaction
26
+
27
+ async def execute_query(self, query, **kwargs):
28
+ parameters = dict(kwargs.pop('params', None) or {})
29
+ # The transaction already owns its session/routing. Reads must observe
30
+ # its uncommitted writes instead of opening an independent read session.
31
+ kwargs.pop('routing_', None)
32
+ parameters.update(kwargs)
33
+ result = await self._transaction.run(query, **parameters)
34
+ records = [record async for record in result]
35
+ return records, None, list(records[0].keys()) if records else []
36
+
37
+ @asynccontextmanager
38
+ async def transaction(self):
39
+ # Nested query-only operations join the owner; only it commits/rolls back.
40
+ yield self._transaction
@@ -0,0 +1,50 @@
1
+ """Durable lifecycle metadata, never prompts, local paths or transcripts."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import Field, model_validator
6
+
7
+ from .models import SourceApplication, StrictModel
8
+ from .model_validation import reject_credentials
9
+ from .project_agent_memory_models import ProjectAgentWorkingMemory
10
+
11
+
12
+ class TaskContextBegin(StrictModel):
13
+ personal_space_id: str = Field(min_length=1, max_length=128)
14
+ personal_project_id: str | None = Field(default=None, min_length=1, max_length=128)
15
+ project_agent_id: str | None = Field(default=None, min_length=1, max_length=128)
16
+ session_id: str = Field(min_length=1, max_length=256)
17
+ source_application: SourceApplication
18
+ source_session_id: str | None = Field(default=None, min_length=1, max_length=256)
19
+ token: str = Field(pattern=r'^fuli-task-[a-zA-Z0-9-]{8,128}$')
20
+ turn_id: str | None = Field(default=None, min_length=1, max_length=256)
21
+ memory_revision: int | None = Field(default=None, ge=0)
22
+
23
+ @model_validator(mode='after')
24
+ def require_project_for_agent(self):
25
+ if self.project_agent_id and not self.personal_project_id:
26
+ raise ValueError('Agent lifecycle requires an exact project')
27
+ return self
28
+
29
+
30
+ class TaskContextAgentMemory(StrictModel):
31
+ expected_revision: int = Field(ge=0)
32
+ memory: ProjectAgentWorkingMemory
33
+
34
+
35
+ class TaskContextCheckpoint(StrictModel):
36
+ personal_space_id: str = Field(min_length=1, max_length=128)
37
+ source_application: SourceApplication
38
+ phase: Literal['prepare', 'complete'] = 'complete'
39
+ disposition: Literal['capture_candidates', 'retain_nothing']
40
+ reason: str = Field(min_length=1, max_length=2000)
41
+ fingerprint: str = Field(pattern=r'^[a-f0-9]{64}$')
42
+ capture_status: str | None = Field(default=None, max_length=128)
43
+ agent_memory: TaskContextAgentMemory | None = None
44
+
45
+ @model_validator(mode='after')
46
+ def reject_sensitive_values(self):
47
+ if self.agent_memory is not None and self.phase != 'prepare':
48
+ raise ValueError('Agent memory belongs to atomic checkpoint preparation')
49
+ reject_credentials(self, 'task checkpoint')
50
+ return self
@@ -0,0 +1,31 @@
1
+ from typing import Annotated
2
+
3
+ from fastapi import Query
4
+
5
+ from .models import SourceApplication
6
+ from .task_context_models import TaskContextBegin, TaskContextCheckpoint
7
+
8
+
9
+ def register_task_context_routes(application, store, Actor):
10
+ @application.put('/v1/task-contexts')
11
+ async def begin_context(request: TaskContextBegin, actor: Actor):
12
+ return await store.begin_task_context(actor, request)
13
+
14
+ @application.get('/v1/task-contexts/{token}')
15
+ async def get_context(token: str, actor: Actor,
16
+ personal_space_id: Annotated[str, Query(min_length=1, max_length=128)],
17
+ source_application: SourceApplication):
18
+ return await store.get_task_context(actor, personal_space_id, token, source_application)
19
+
20
+ @application.put('/v1/task-contexts/{token}/checkpoint')
21
+ async def checkpoint_context(token: str, request: TaskContextCheckpoint, actor: Actor):
22
+ return await store.checkpoint_task_context(actor, token, request)
23
+
24
+ @application.get('/v1/task-context-sessions/checkpoint')
25
+ async def verify_checkpoint(actor: Actor,
26
+ personal_space_id: Annotated[str, Query(min_length=1, max_length=128)],
27
+ session_id: Annotated[str, Query(min_length=1, max_length=256)],
28
+ source_application: SourceApplication):
29
+ return await store.verify_task_checkpoint(
30
+ actor, personal_space_id, session_id, source_application,
31
+ )
@@ -24,7 +24,7 @@ class WorkflowTransitionObservation(StrictModel):
24
24
  observed_at: datetime
25
25
  evidence_summary: str = Field(min_length=1, max_length=2048)
26
26
  source_application: Literal[
27
- 'codex', 'claude_code', 'cursor', 'kiro', 'other'
27
+ 'codex', 'claude', 'claude_code', 'cursor', 'kiro', 'other'
28
28
  ] | None = None
29
29
  source_turn_id: str | None = Field(default=None, min_length=1, max_length=256)
30
30
  sensitivity: Literal['normal', 'private', 'restricted'] = 'normal'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fuli-graph-provider"
3
- version = "0.7.5"
3
+ version = "0.7.7"
4
4
  description = "Graphiti-backed personal and shared project provider for Fuli."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "fuli-context",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "fuli-context",
9
- "version": "0.7.5",
9
+ "version": "0.7.7",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@hono/node-server": "2.0.12",
@@ -2950,9 +2950,9 @@
2950
2950
  "license": "MIT"
2951
2951
  },
2952
2952
  "node_modules/fast-uri": {
2953
- "version": "3.1.5",
2954
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
2955
- "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
2953
+ "version": "3.1.7",
2954
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
2955
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
2956
2956
  "funding": [
2957
2957
  {
2958
2958
  "type": "github",
@@ -4397,9 +4397,9 @@
4397
4397
  }
4398
4398
  },
4399
4399
  "node_modules/qs": {
4400
- "version": "6.15.3",
4401
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
4402
- "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
4400
+ "version": "6.16.0",
4401
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
4402
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
4403
4403
  "license": "BSD-3-Clause",
4404
4404
  "dependencies": {
4405
4405
  "es-define-property": "^1.0.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fuli-context",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Local-first collaboration relationship graph for AI agents.",
5
5
  "type": "module",
6
6
  "files": [
@@ -10,6 +10,8 @@
10
10
  "dist/web/",
11
11
  "docs/external-knowledge-architecture.md",
12
12
  "docs/public-personal-architecture.md",
13
+ "docs/project-agent-memory.md",
14
+ "docs/employee-agents.md",
13
15
  "examples/external-knowledge/markdown-folder.mjs",
14
16
  "graph-provider/Dockerfile",
15
17
  "graph-provider/pyproject.toml",
@@ -99,6 +101,7 @@
99
101
  "@hono/node-server": "2.0.12"
100
102
  },
101
103
  "brace-expansion": "$brace-expansion",
102
- "fast-uri": "3.1.5"
104
+ "fast-uri": "3.1.7",
105
+ "qs": "6.16.0"
103
106
  }
104
107
  }
@@ -243,14 +243,26 @@ including concrete worker identity. Call `record_project_agent_executor_actual`
243
243
  executor/model use. If the host cannot start a worker, report the task as blocked or failed and do
244
244
  not fabricate an execution-summary row. The Provider's `executionSummary` remains authoritative.
245
245
 
246
+ Keep per-worker completion separate from the global task transition. While parallel work is still
247
+ being integrated, report each worker's terminal result in `workerStatus` and keep the task status
248
+ `running`; this preserves one terminal execution-summary row per actual worker. Only after every
249
+ required worker or participant result has been recorded should the coordinator send the final task
250
+ `completed`, `failed`, or `cancelled` activity. Never let the first completed worker close the task
251
+ before the remaining workers have reported their tools, session evidence, and exact Token usage.
252
+
246
253
  ## Report Actual Worker Execution
247
254
 
248
- When a FULI task or task view returns a non-empty Provider `executionSummary`, append one row per worker.
249
- Include the worker label and occupation emoji, actual executor and `sourceApplication` environment,
250
- work summary, and `workerStatus`/terminal status. This applies only to Provider-reported actual workers.
251
- When empty `executionSummary` is returned, omit rows; an absent `executionSummary` also omits rows.
252
- Configured, allowed, or available clients/executors are not evidence of actual use. Keep this
253
- Agent-agnostic; client-specific details and work-kind routing remain at adapter boundaries.
255
+ When a FULI task or task view returns a non-empty Provider `executionSummary`, append a compact
256
+ Markdown table with one row per actual worker. Include the worker label and occupation emoji,
257
+ actual executor/tool plus `sourceApplication`, work summary, terminal `workerStatus`/status,
258
+ session (`sourceSessionId`, or a session link only when the Provider supplies one), and Token usage.
259
+ Use only the source-labelled cumulative `tokenUsage` reported for that worker. If it is absent,
260
+ show `not reported`; never estimate it or copy a conversation-wide total into a worker row. If no
261
+ session link exists, show `sourceSessionId` or `not reported`; never invent a link. This applies only
262
+ to Provider-reported actual workers. When empty `executionSummary` is returned, omit rows; an absent
263
+ `executionSummary` also omits rows. Configured, allowed, or available clients/executors are not evidence
264
+ of actual use. Keep this Agent-agnostic; client-specific details and work-kind routing remain at
265
+ adapter boundaries.
254
266
 
255
267
  ## Classify Knowledge State
256
268
 
@@ -1,3 +1,5 @@
1
+ const STOP_FAILURE_RETRY_MS = 30_000;
2
+
1
3
  export function createExecutorPool({
2
4
  adapters = new Map(),
3
5
  idleMs,
@@ -6,6 +8,7 @@ export function createExecutorPool({
6
8
  }) {
7
9
  const entries = new Map();
8
10
  let closed = false;
11
+ let closePromise = null;
9
12
 
10
13
  async function acquire(executorId, leaseId) {
11
14
  assertExecutorId(executorId);
@@ -21,20 +24,27 @@ export function createExecutorPool({
21
24
  return snapshotEntry(executorId, entry);
22
25
  }
23
26
  await exclusive(entry, async () => {
27
+ if (closed) throw new Error('Executor pool is closed');
24
28
  if (entry.stage !== 'running') {
25
- entry.stage = 'starting';
26
29
  try {
30
+ if (entry.stage === 'failed' && !await stopEntry(executorId, entry)) {
31
+ throw new Error(entry.lastError);
32
+ }
33
+ entry.stage = 'starting';
27
34
  await entry.adapter.start({ executorId });
28
35
  entry.stage = 'running';
36
+ entry.lastStartError = null;
29
37
  entry.lastError = null;
30
38
  } catch (error) {
31
39
  entry.leases.delete(leaseId);
40
+ if (entry.stage === 'starting') entry.lastStartError = errorMessage(error);
32
41
  entry.stage = 'failed';
33
42
  entry.lastError = errorMessage(error);
34
43
  throw error;
35
44
  }
36
45
  }
37
46
  });
47
+ if (closed) throw new Error('Executor pool is closed');
38
48
  return snapshotEntry(executorId, entry);
39
49
  }
40
50
 
@@ -42,15 +52,27 @@ export function createExecutorPool({
42
52
  const entry = entries.get(executorId);
43
53
  if (!entry) return;
44
54
  entry.leases.delete(leaseId);
55
+ // Runtime entries are live lease bookkeeping, not durable Agent identities.
56
+ // External hosts have no resources owned by this pool after their last lease.
57
+ if (!entry.adapter && entry.leases.size === 0) {
58
+ entries.delete(executorId);
59
+ return;
60
+ }
61
+ scheduleStop(executorId, entry, idleMs);
62
+ }
63
+
64
+ function scheduleStop(executorId, entry, delay) {
45
65
  if (!entry.adapter || entry.leases.size > 0 || entry.timer || closed) return;
46
66
  entry.timer = setTimer(() => {
47
67
  entry.timer = null;
48
68
  if (entry.leases.size > 0 || closed) return;
49
69
  void exclusive(entry, async () => {
50
70
  if (entry.leases.size > 0 || closed) return;
51
- await stopEntry(executorId, entry);
71
+ if (!await stopEntry(executorId, entry)) {
72
+ scheduleStop(executorId, entry, STOP_FAILURE_RETRY_MS);
73
+ }
52
74
  });
53
- }, idleMs);
75
+ }, delay);
54
76
  entry.timer?.unref?.();
55
77
  }
56
78
 
@@ -59,10 +81,12 @@ export function createExecutorPool({
59
81
  try {
60
82
  await entry.adapter.stop({ executorId });
61
83
  entry.stage = 'idle';
62
- entry.lastError = null;
84
+ entry.lastError = entry.lastStartError;
85
+ return true;
63
86
  } catch (error) {
64
87
  entry.stage = 'failed';
65
88
  entry.lastError = errorMessage(error);
89
+ return false;
66
90
  }
67
91
  }
68
92
 
@@ -72,11 +96,27 @@ export function createExecutorPool({
72
96
  }
73
97
 
74
98
  function close() {
99
+ if (closePromise) return closePromise;
75
100
  closed = true;
76
- for (const entry of entries.values()) {
101
+ const shutdowns = [];
102
+ for (const [executorId, entry] of entries) {
77
103
  if (entry.timer) clearTimer(entry.timer);
78
104
  entry.timer = null;
105
+ entry.leases.clear();
106
+ if (entry.adapter) shutdowns.push(exclusive(entry, async () => {
107
+ if (entry.stage !== 'idle' && !await stopEntry(executorId, entry)) {
108
+ throw new Error(`Failed to stop managed executor ${executorId}: ${entry.lastError}`);
109
+ }
110
+ }));
79
111
  }
112
+ closePromise = Promise.allSettled(shutdowns).then((results) => {
113
+ const failures = results.filter((result) => result.status === 'rejected')
114
+ .map((result) => result.reason);
115
+ if (failures.length) {
116
+ throw new AggregateError(failures, failures.map((error) => error.message).join('; '));
117
+ }
118
+ });
119
+ return closePromise;
80
120
  }
81
121
 
82
122
  function entryFor(executorId) {
@@ -88,6 +128,7 @@ export function createExecutorPool({
88
128
  timer: null,
89
129
  transition: Promise.resolve(),
90
130
  stage: adapters.has(executorId) ? 'idle' : 'external',
131
+ lastStartError: null,
91
132
  lastError: null
92
133
  };
93
134
  entries.set(executorId, entry);
@@ -2,6 +2,7 @@ import { readJsonFile } from '../storage/json-file.js';
2
2
  import { createNativeGraphServices } from '../native-runtime/runtime.js';
3
3
  import {
4
4
  ensureContainerRuntime,
5
+ inspectContainerRuntime,
5
6
  runDockerCompose
6
7
  } from '../setup/container-runtime.js';
7
8
  import {
@@ -38,6 +39,7 @@ export function createManagedGraphServices({
38
39
  readJson = readJsonFile,
39
40
  readSettings = readRuntimeSettings,
40
41
  ensureRuntime = ensureContainerRuntime,
42
+ inspectRuntime = inspectContainerRuntime,
41
43
  runCompose = runDockerCompose,
42
44
  fetchImpl = globalThis.fetch,
43
45
  wait = delay,
@@ -80,15 +82,20 @@ export function createManagedGraphServices({
80
82
  }
81
83
 
82
84
  async function stopProviders() {
85
+ const selectedRuntime = inspectRuntime();
86
+ if (selectedRuntime.status === 'stopped') return;
83
87
  const services = managedServices();
84
- compose(['stop', '-t', '20', ...services.providers], await runtime());
88
+ compose(['stop', '-t', '20', ...services.providers], selectedRuntime);
85
89
  }
86
90
 
87
91
  async function stopDatabases() {
92
+ // Inspect only: shutdown must not launch a desktop container engine to stop it.
93
+ const selectedRuntime = inspectRuntime();
94
+ if (selectedRuntime.status === 'stopped') return;
88
95
  const services = managedServices();
89
96
  // Keep shutdown ordering explicit even if an external action restarted a Provider.
90
- compose(['stop', '-t', '20', ...services.providers], await runtime());
91
- compose(['stop', '-t', '20', ...services.databases], await runtime());
97
+ compose(['stop', '-t', '20', ...services.providers], selectedRuntime);
98
+ compose(['stop', '-t', '20', ...services.databases], selectedRuntime);
92
99
  }
93
100
 
94
101
  async function ready() {