fuli-context 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +4 -0
  2. package/README.zh-CN.md +4 -1
  3. package/dist/web/assets/{AboutPage-BjMmpwJB.js → AboutPage-fBq5VFoQ.js} +1 -1
  4. package/dist/web/assets/AgentHand-CMZk5GjY.js +1 -0
  5. package/dist/web/assets/AgentName-Cydd0UI4.css +1 -0
  6. package/dist/web/assets/AgentName-DRZHAIB2.js +1 -0
  7. package/dist/web/assets/AgentProfilePage-BPQEfMhB.js +1 -0
  8. package/dist/web/assets/AgentsDirectoryPage-CKZfrcuD.js +1 -0
  9. package/dist/web/assets/{ConnectionsPage-BKGxFDos.js → ConnectionsPage-COb56qnk.js} +1 -1
  10. package/dist/web/assets/EmployeeRecruitDialog-BLTpjCcK.js +1 -0
  11. package/dist/web/assets/EmployeeWorkbenchPage-CQwZnTA0.css +1 -0
  12. package/dist/web/assets/EmployeeWorkbenchPage-HUd-gne2.js +3 -0
  13. package/dist/web/assets/{GrowthLoading-WXCj8b0r.js → GrowthLoading-Bu_kH-I8.js} +1 -1
  14. package/dist/web/assets/{KnowledgeEditDialog-Bkie08ZA.js → KnowledgeEditDialog-BTwXXRCD.js} +1 -1
  15. package/dist/web/assets/{KnowledgeInspector-Cez-Z_95.js → KnowledgeInspector-Ct5hl2--.js} +1 -1
  16. package/dist/web/assets/{KnowledgeOrganizerPage-BYmTPA_8.js → KnowledgeOrganizerPage-DMC8Hh43.js} +1 -1
  17. package/dist/web/assets/KnowledgePage-uXW1Eovz.js +1 -0
  18. package/dist/web/assets/{KnowledgeWorkspace-DKij2_B4.js → KnowledgeWorkspace-DmHIc0jb.js} +1 -1
  19. package/dist/web/assets/{NotFoundPage-HGV-q-bh.js → NotFoundPage-C0yOR9T9.js} +1 -1
  20. package/dist/web/assets/{OverviewPage-DE29_F_Y.js → OverviewPage-BreX_kb4.js} +1 -1
  21. package/dist/web/assets/{PersonalProfilePage-B95DFc7x.js → PersonalProfilePage-CX1a6_E3.js} +1 -1
  22. package/dist/web/assets/PersonalProjectsPage-Br1S8ETM.js +1 -0
  23. package/dist/web/assets/ProjectAgentsPage-CA04h1Uf.js +13 -0
  24. package/dist/web/assets/ProjectAgentsPage-CZWWV4Kw.css +1 -0
  25. package/dist/web/assets/{ProjectScopePicker-Cu5PdxF6.js → ProjectScopePicker-DVHfXX1f.js} +1 -1
  26. package/dist/web/assets/{PublicProjectsPage-BY4B3_t3.js → PublicProjectsPage-CtctKml1.js} +1 -1
  27. package/dist/web/assets/{ReviewPage-C9vCesNu.js → ReviewPage-DUqaK-do.js} +1 -1
  28. package/dist/web/assets/{SearchableSelect-214TvfYg.js → SearchableSelect-BZtyYmZN.js} +1 -1
  29. package/dist/web/assets/{SettingsPage-D9TnzcFc.js → SettingsPage-CB4ymLr8.js} +1 -1
  30. package/dist/web/assets/{SharedJefaReportPage-CdSGI_OE.js → SharedJefaReportPage-CbFtrDCY.js} +1 -1
  31. package/dist/web/assets/{WritingTastePage-CUE1dU8f.js → WritingTastePage-B7lSzs2z.js} +1 -1
  32. package/dist/web/assets/agent-profile-CdKOh5xw.css +1 -0
  33. package/dist/web/assets/agent-profile.css_vue_type_style_index_0_src_true_lang-C22UdfU0.js +1 -0
  34. package/dist/web/assets/catalog-DU4Kfd-N.js +1 -0
  35. package/dist/web/assets/{client-Bl4oiW3E.js → client-CEHkJJ-5.js} +1 -1
  36. package/dist/web/assets/{console-BAIVNC-N.js → console--CZxc3PW.js} +1 -1
  37. package/dist/web/assets/{conversation-launcher-settings-DpRI_Jp2.js → conversation-launcher-settings-Co1RlTFH.js} +1 -1
  38. package/dist/web/assets/{i18n-CVFADb_M.js → i18n-BtOOoyJd.js} +3 -3
  39. package/dist/web/assets/{identity-Dt0WZgqo.js → identity-CsowvtIm.js} +1 -1
  40. package/dist/web/assets/index-7X3LGByg.js +2 -0
  41. package/dist/web/assets/index-CWMqDq8g.css +1 -0
  42. package/dist/web/assets/{model-B8sOxih0.js → model-CGpllrds.js} +1 -1
  43. package/dist/web/assets/{personal-profile-graph-aAqex7ty.js → personal-profile-graph-BNTG4PSY.js} +1 -1
  44. package/dist/web/assets/profile-model-BQO4zNs9.js +1 -0
  45. package/dist/web/assets/{shared-report-BQD6l_cS.js → shared-report-BFUJqqQe.js} +1 -1
  46. package/dist/web/assets/task-evidence-BUu7yYIn.js +1 -0
  47. package/dist/web/assets/{useApi-CROJJdhE-CuJ16v2S.js → useApi-CROJJdhE-raNlHRDl.js} +1 -1
  48. package/dist/web/assets/{useModalDialog-Ddv2XJQi.js → useModalDialog-CroGk2Cs.js} +1 -1
  49. package/dist/web/assets/{vue-router-C8KuEZ6a.js → vue-router-JFYBLXB1.js} +1 -1
  50. package/dist/web/index.html +12 -10
  51. package/docs/agent-conversations-and-collaboration.md +115 -0
  52. package/docs/agent-interface-architecture.md +17 -0
  53. package/docs/employee-agents.md +14 -1
  54. package/docs/project-agent-memory.md +34 -1
  55. package/graph-provider/fuli_graph/agent_conversation_models.py +72 -0
  56. package/graph-provider/fuli_graph/agent_conversation_routes.py +47 -0
  57. package/graph-provider/fuli_graph/agent_loan_models.py +28 -0
  58. package/graph-provider/fuli_graph/agent_quality_policy.py +151 -0
  59. package/graph-provider/fuli_graph/agent_verification_models.py +22 -0
  60. package/graph-provider/fuli_graph/conversation_context.py +30 -0
  61. package/graph-provider/fuli_graph/knowledge_search.py +8 -16
  62. package/graph-provider/fuli_graph/project_agent_executor_models.py +3 -0
  63. package/graph-provider/fuli_graph/project_agent_memory_routes.py +2 -0
  64. package/graph-provider/fuli_graph/project_agent_models.py +10 -0
  65. package/graph-provider/fuli_graph/project_agent_routes.py +5 -1
  66. package/graph-provider/fuli_graph/project_agent_task_models.py +4 -0
  67. package/graph-provider/fuli_graph/runtime.py +14 -0
  68. package/graph-provider/fuli_graph/store.py +6 -0
  69. package/graph-provider/fuli_graph/store_agent_conversations.py +206 -0
  70. package/graph-provider/fuli_graph/store_agent_loans.py +146 -0
  71. package/graph-provider/fuli_graph/store_agent_verification.py +111 -0
  72. package/graph-provider/fuli_graph/store_project_agent_executors.py +2 -0
  73. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +6 -0
  74. package/graph-provider/fuli_graph/store_project_agent_tasks.py +20 -8
  75. package/graph-provider/fuli_graph/store_task_contexts.py +9 -0
  76. package/graph-provider/fuli_graph/task_context_routes.py +7 -0
  77. package/graph-provider/pyproject.toml +1 -1
  78. package/npm-shrinkwrap.json +15 -15
  79. package/package.json +4 -3
  80. package/skills/capturing-session-knowledge/SKILL.md +20 -2
  81. package/skills/operating-fuli/SKILL.md +4 -0
  82. package/src/agent-tools/agent-collaboration-definitions.js +30 -0
  83. package/src/agent-tools/agent-conversation-definitions.js +21 -0
  84. package/src/agent-tools/employee-definitions.js +6 -5
  85. package/src/agent-tools/graph-definitions.js +19 -35
  86. package/src/agent-tools/graph-handlers.js +17 -1
  87. package/src/agent-tools/interface-catalog.js +2 -0
  88. package/src/agent-tools/project-agent-definitions.js +21 -0
  89. package/src/agent-tools/project-relation-definitions.js +36 -0
  90. package/src/agents/claude-code/conversation-transcript.js +20 -0
  91. package/src/agents/claude-code/lifecycle-hook.js +154 -0
  92. package/src/agents/claude-code/lifecycle-hooks.js +20 -0
  93. package/src/agents/codex/conversation-transcript.js +22 -0
  94. package/src/agents/codex/lifecycle-hook.js +47 -8
  95. package/src/agents/codex/lifecycle-hooks.js +6 -6
  96. package/src/agents/cursor/lifecycle-hook.js +5 -4
  97. package/src/app/agent-request-context.js +6 -1
  98. package/src/app/test-tools.js +26 -0
  99. package/src/conversations/sync-transcript.js +144 -0
  100. package/src/conversations/task-context-view.js +28 -0
  101. package/src/conversations/transcript-guard.js +54 -0
  102. package/src/conversations/transcript-reader.js +113 -0
  103. package/src/employees/service.js +8 -1
  104. package/src/graphiti/agent-collaboration.js +62 -0
  105. package/src/graphiti/agent-conversations.js +75 -0
  106. package/src/graphiti/agent-knowledge-workflows.js +8 -2
  107. package/src/graphiti/project-agent-control-plane.js +22 -2
  108. package/src/graphiti/project-agent-mapping.js +19 -1
  109. package/src/graphiti/project-agent-task-entry.js +11 -1
  110. package/src/graphiti/project-agent-workflows.js +11 -1
  111. package/src/graphiti/project-path-context.js +17 -10
  112. package/src/graphiti/provider-client.js +17 -0
  113. package/src/graphiti/verification-preference.js +24 -0
  114. package/src/http/graph-api-router.js +17 -0
  115. package/src/mcp/create-mcp-server.js +19 -5
  116. package/src/mcp/employee-tool-catalog.js +22 -0
  117. package/src/mcp/session-id.js +4 -0
  118. package/src/mcp/tool-annotations.js +8 -1
  119. package/src/mcp/tool-result.js +3 -1
  120. package/src/setup/agent-installation-status.js +1 -0
  121. package/src/setup/claude-code-config.js +13 -19
  122. package/dist/web/assets/AgentHand-BfLYPGjg.js +0 -1
  123. package/dist/web/assets/EmployeeRecruitDialog-D-W043v4.js +0 -1
  124. package/dist/web/assets/EmployeeWorkbenchPage-Cz7y-IFD.css +0 -1
  125. package/dist/web/assets/EmployeeWorkbenchPage-HHQPxdqI.js +0 -3
  126. package/dist/web/assets/KnowledgePage-Ck7tIBDn.js +0 -1
  127. package/dist/web/assets/PersonalProjectsPage-Bt7tFF5Z.js +0 -1
  128. package/dist/web/assets/ProjectAgentsPage-BioDR9OW.css +0 -1
  129. package/dist/web/assets/ProjectAgentsPage-CiiPNe_G.js +0 -12
  130. package/dist/web/assets/index-BUMq5zNh.js +0 -2
  131. package/dist/web/assets/index-C0ci8dnU.css +0 -1
  132. package/src/mcp/task-context-registry.js +0 -165
@@ -0,0 +1,151 @@
1
+ """Deterministic quality gate and capability routing, without I/O or model calls.
2
+
3
+ The caller owns trusted evidence collection and persists ordered attempts. A
4
+ selected model is only a recommendation: selection never proves execution or
5
+ successful verification. Artifact revisions must identify the checked artifact.
6
+ """
7
+ from collections.abc import Mapping
8
+ from copy import deepcopy
9
+ from math import isfinite
10
+
11
+
12
+ COMPLEXITY_TIERS = {'simple': 1, 'standard': 2, 'complex': 3}
13
+ STRATEGY_TIERS = {'fast': 1, 'balanced': 2, 'deep': 3, 'adaptive': 1}
14
+
15
+
16
+ def _tier(value):
17
+ return type(value) is int and 1 <= value <= 3
18
+
19
+
20
+ def _text(value):
21
+ return isinstance(value, str) and bool(value.strip())
22
+
23
+
24
+ def _unique_attempts(attempts):
25
+ seen = {}
26
+ unique = []
27
+ for attempt in attempts:
28
+ if not isinstance(attempt, Mapping):
29
+ raise ValueError('Each verification attempt must be an object')
30
+ identifier = attempt.get('attempt_id')
31
+ refs = attempt.get('evidence_refs')
32
+ if not _text(identifier) or not _text(attempt.get('artifact_revision')):
33
+ raise ValueError('attempt_id and artifact_revision must be nonempty strings')
34
+ if attempt.get('outcome') not in ('pass', 'fail'):
35
+ raise ValueError('Attempt outcome must be pass or fail')
36
+ if not _tier(attempt.get('capability_tier')):
37
+ raise ValueError('Attempt capability_tier must be an observed integer from 1 to 3')
38
+ if not isinstance(refs, (list, tuple)) or not all(_text(ref) for ref in refs):
39
+ raise ValueError('evidence_refs must contain nonempty string references')
40
+ if identifier in seen:
41
+ if seen[identifier] != attempt:
42
+ raise ValueError(f'Conflicting attempt_id: {identifier}')
43
+ continue
44
+ seen[identifier] = attempt
45
+ unique.append(attempt)
46
+ return unique
47
+
48
+
49
+ def _select_model(models, minimum):
50
+ eligible = []
51
+ for model in models:
52
+ if not isinstance(model, Mapping):
53
+ continue
54
+ tier, cost = model.get('capability_tier'), model.get('cost_rank')
55
+ if model.get('available') is not True or not _tier(tier) or tier < minimum:
56
+ continue
57
+ if type(cost) not in (int, float) or cost < 0:
58
+ continue
59
+ if type(cost) is float and not isfinite(cost):
60
+ continue
61
+ if not _text(model.get('provider')) or not _text(model.get('model')):
62
+ continue
63
+ eligible.append(model)
64
+ if not eligible:
65
+ return None
66
+ selected = min(eligible, key=lambda item: (
67
+ item['cost_rank'], item['provider'], item['model'], -item['capability_tier'],
68
+ ))
69
+ # Return only routing metadata, detached from the caller's mutable catalog.
70
+ return deepcopy({key: selected[key] for key in (
71
+ 'provider', 'model', 'capability_tier', 'cost_rank', 'available',
72
+ )})
73
+
74
+
75
+ def evaluate_quality_policy(*, complexity, current_strategy, artifact_revision,
76
+ attempts=(), available_models=(), current_capability_tier=None):
77
+ """Return a quality gate and the cheapest observed model meeting its floor.
78
+
79
+ ``current_strategy`` is a mode string or a mapping containing ``mode``.
80
+ Unproven attempts are retained in the count but cannot pass the gate, reset
81
+ failure streaks, or trigger escalation. Distinct evidence-backed failures
82
+ accumulate per capability tier across artifact revisions. A proved pass
83
+ resets its tier's streak, without erasing an already-earned capability floor.
84
+
85
+ Status is one of ``verification_required``, ``passed``, ``needs_human``, or
86
+ ``blocked``. Two failures at tier 3 stop automatic selection. A later valid
87
+ tier-3 pass can resolve that gate, for example after human-directed repair.
88
+ ``escalation_required`` means the floor rose above the declared current
89
+ capability; it does not mean an upgraded executor has already run.
90
+ """
91
+ mode = current_strategy.get('mode') if isinstance(current_strategy, Mapping) else current_strategy
92
+ if not isinstance(complexity, str) or complexity not in COMPLEXITY_TIERS:
93
+ raise ValueError('Unknown task complexity')
94
+ if not isinstance(mode, str) or mode not in STRATEGY_TIERS:
95
+ raise ValueError('Unknown model strategy')
96
+ if not _text(artifact_revision):
97
+ raise ValueError('artifact_revision must be a nonempty string')
98
+ if current_capability_tier is not None and not _tier(current_capability_tier):
99
+ raise ValueError('current_capability_tier must be an integer from 1 to 3')
100
+ declared_tier = max(STRATEGY_TIERS[mode], current_capability_tier or 1)
101
+ minimum = max(COMPLEXITY_TIERS[complexity], declared_tier)
102
+ consecutive = {1: 0, 2: 0, 3: 0}
103
+ unique = _unique_attempts(attempts)
104
+ ignored = []
105
+ last_current = None
106
+ escalation_attempt_ids = []
107
+ needs_human = False
108
+ for attempt in unique:
109
+ if not attempt['evidence_refs']:
110
+ ignored.append(attempt['attempt_id'])
111
+ continue
112
+ tier = attempt['capability_tier']
113
+ # Evidence that a higher tier already ran prevents a silent downgrade.
114
+ minimum = max(minimum, tier)
115
+ if attempt['outcome'] == 'fail':
116
+ consecutive[tier] += 1
117
+ if consecutive[tier] == 2:
118
+ minimum = max(minimum, min(3, tier + 1))
119
+ escalation_attempt_ids.append(attempt['attempt_id'])
120
+ if tier == 3:
121
+ needs_human = True
122
+ else:
123
+ consecutive[tier] = 0
124
+ if tier == 3 and attempt['artifact_revision'] == artifact_revision:
125
+ needs_human = False
126
+ if attempt['artifact_revision'] == artifact_revision:
127
+ last_current = attempt
128
+
129
+ result = {
130
+ 'status': 'verification_required',
131
+ 'minimum_capability_tier': minimum,
132
+ 'escalation_required': minimum > declared_tier,
133
+ 'selected_model': None,
134
+ 'artifact_revision': artifact_revision,
135
+ 'verified_attempt_id': None,
136
+ 'attempt_count': len(unique),
137
+ 'ignored_attempt_ids': ignored,
138
+ 'consecutive_failures': consecutive,
139
+ 'escalation_attempt_ids': escalation_attempt_ids,
140
+ }
141
+ if needs_human:
142
+ result['status'] = 'needs_human'
143
+ elif (last_current is not None and last_current['outcome'] == 'pass'
144
+ and last_current['capability_tier'] >= minimum):
145
+ result['status'] = 'passed'
146
+ result['verified_attempt_id'] = last_current['attempt_id']
147
+ else:
148
+ result['selected_model'] = _select_model(available_models, minimum)
149
+ if result['selected_model'] is None:
150
+ result['status'] = 'blocked'
151
+ return result
@@ -0,0 +1,22 @@
1
+ from typing import Annotated, Literal
2
+ from pydantic import Field
3
+ from .models import StrictModel, SourceApplication
4
+
5
+
6
+ class AgentQualityQuery(StrictModel):
7
+ personal_space_id: str = Field(min_length=1, max_length=128)
8
+ personal_project_id: str = Field(min_length=1, max_length=128)
9
+ task_id: str = Field(min_length=1, max_length=128)
10
+ artifact_revision: str = Field(min_length=1, max_length=160)
11
+
12
+
13
+ class AgentVerification(AgentQualityQuery):
14
+ run_id: str = Field(min_length=1, max_length=128)
15
+ task_context_token: str = Field(min_length=1, max_length=160)
16
+ source_application: SourceApplication
17
+ attempt_id: str = Field(min_length=8, max_length=128)
18
+ outcome: Literal['pass', 'fail']
19
+ evidence_refs: list[Annotated[str, Field(min_length=1, max_length=512, pattern=r'\S')]] = Field(min_length=1, max_length=12)
20
+ executor_id: str = Field(min_length=1, max_length=128)
21
+ provider: str = Field(min_length=1, max_length=128)
22
+ model: str = Field(min_length=1, max_length=256)
@@ -0,0 +1,30 @@
1
+ """Deterministic recovery: no model calls, conservative UTF-8 byte accounting."""
2
+ import json
3
+ from datetime import datetime, timezone
4
+
5
+
6
+ def archived(last_activity, idle_days, *, now=None):
7
+ current = datetime.fromisoformat(now) if now else datetime.now(timezone.utc)
8
+ previous = datetime.fromisoformat(last_activity)
9
+ return (current - previous).total_seconds() >= idle_days * 86400
10
+
11
+
12
+ def pack_context(summary, events, budget):
13
+ result = {'summary': '', 'messages': [], 'truncated': False}
14
+ def size():
15
+ return len(json.dumps(result, ensure_ascii=False, separators=(',', ':')).encode())
16
+ result['summary'] = summary or ''
17
+ # Reserve half for recent messages; Unicode slicing never splits codepoints.
18
+ while len(result['summary'].encode()) > budget // 2:
19
+ result['summary'] = result['summary'][:max(0, len(result['summary']) - 64)]
20
+ result['truncated'] = True
21
+ for event in reversed(events):
22
+ if event['role'] not in ('user', 'assistant') or event.get('kind', 'message') != 'message':
23
+ continue
24
+ item = {'role': event['role'], 'content': event['content']}
25
+ result['messages'].insert(0, item)
26
+ if size() > budget:
27
+ result['messages'].pop(0)
28
+ result['truncated'] = True
29
+ break
30
+ return result
@@ -496,21 +496,6 @@ def _item_scope_metadata(
496
496
  project_scopes: dict[str, dict],
497
497
  include_personal_global: bool,
498
498
  ) -> dict | None:
499
- if not any(
500
- field in record
501
- for field in (
502
- 'profile_aspect',
503
- 'preference_agent_id',
504
- 'assignment_project_id',
505
- 'episode_project_ids',
506
- 'episode_project_agent_ids',
507
- 'reference_project_ids',
508
- 'has_global_episode',
509
- )
510
- ):
511
- # Backward-compatible projection for older Provider clients that returned
512
- # only already-filtered item IDs.
513
- return _global_scope_metadata()
514
499
  profile_aspect = record.get('profile_aspect')
515
500
  if profile_aspect:
516
501
  preference_scope = record.get('preference_scope') or 'global'
@@ -592,7 +577,14 @@ def _item_scope_metadata(
592
577
  ),
593
578
  )
594
579
  return _project_scope_metadata(project_id, scope)
595
- if include_personal_global and record.get('has_global_episode'):
580
+ # A later project assignment, or an existing project origin, takes
581
+ # precedence over a historical unscoped episode. It is not global access.
582
+ if (
583
+ include_personal_global
584
+ and record.get('has_global_episode')
585
+ and not assignment_project_id
586
+ and not episode_project_ids
587
+ ):
596
588
  return _global_scope_metadata()
597
589
  return None
598
590
 
@@ -150,6 +150,8 @@ class ProjectAgentExecutorModelRecord(StrictModel):
150
150
  provider: str = Field(min_length=1, max_length=128)
151
151
  model: str = Field(min_length=1, max_length=256)
152
152
  capabilities: list[str] = Field(default_factory=list, max_length=32)
153
+ capability_tier: int | None = Field(default=None, ge=1, le=3)
154
+ cost_rank: float | None = Field(default=None, ge=0, allow_inf_nan=False)
153
155
  strategy_modes: list[Literal['adaptive', 'fast', 'balanced', 'deep']] = Field(
154
156
  default_factory=list,
155
157
  max_length=4,
@@ -456,6 +458,7 @@ class ProjectAgentExecutorSelection(StrictModel):
456
458
 
457
459
 
458
460
  class ProjectAgentExecutorActualReport(StrictModel):
461
+ artifact_revision: str | None = Field(default=None, min_length=1, max_length=160)
459
462
  personal_space_id: str = Field(min_length=1, max_length=128)
460
463
  personal_project_id: str = Field(min_length=1, max_length=128)
461
464
  task_id: str = Field(min_length=1, max_length=128)
@@ -12,11 +12,13 @@ from .project_agent_memory_models import (
12
12
  from .project_agent_context_models import (
13
13
  ProjectAgentContextRequest, ProjectAgentContextResolution,
14
14
  )
15
+ from .agent_conversation_routes import register_agent_conversation_routes
15
16
  from .task_context_routes import register_task_context_routes
16
17
 
17
18
 
18
19
  def register_project_agent_memory_routes(application, store, Actor):
19
20
  register_task_context_routes(application, store, Actor)
21
+ register_agent_conversation_routes(application, store, Actor)
20
22
  @application.post(
21
23
  '/v1/project-agent-context/resolve', response_model=ProjectAgentContextResolution,
22
24
  )
@@ -88,6 +88,14 @@ class ProjectAgentExecutorPolicy(StrictModel):
88
88
  return self
89
89
 
90
90
 
91
+ class ProjectAgentCharacter(StrictModel):
92
+ """Configured role intent, not a claim about demonstrated performance."""
93
+
94
+ judgment: str = Field(default='', max_length=2048)
95
+ taste: str = Field(default='', max_length=2048)
96
+ personality: str = Field(default='', max_length=2048)
97
+
98
+
91
99
  class ProjectAgentProfile(StrictModel):
92
100
  name: str = Field(min_length=1, max_length=160)
93
101
  display_name: str | None = Field(default=None, min_length=1, max_length=160)
@@ -101,6 +109,8 @@ class ProjectAgentProfile(StrictModel):
101
109
  validation_alias=AliasChoices('occupation_emoji', 'occupationEmoji'),
102
110
  )
103
111
  responsibility: str = Field(min_length=1, max_length=4096)
112
+ character: ProjectAgentCharacter = Field(default_factory=ProjectAgentCharacter)
113
+ expectations: str = Field(default='', max_length=4096)
104
114
  agent_type: ProjectAgentType = 'durable'
105
115
  work_kinds: list[str] = Field(default_factory=list, max_length=32)
106
116
  capabilities: list[str] = Field(default_factory=list, max_length=32)
@@ -253,13 +253,17 @@ def register_project_agent_routes(
253
253
  str | None,
254
254
  Query(min_length=1, max_length=128),
255
255
  ] = None,
256
+ include_events: bool = True,
256
257
  ) -> ProjectAgentTaskRecord:
257
- return await store.get_project_agent_task(
258
+ task = await store.get_project_agent_task(
258
259
  actor,
259
260
  personal_space_id,
260
261
  task_id,
261
262
  personal_project_id=personal_project_id,
262
263
  )
264
+ # Derive execution evidence before omitting history from the response.
265
+ # Never mutate the durable record or erase worker state in compact reads.
266
+ return task if include_events else task.model_copy(update={'events': []})
263
267
 
264
268
  @application.post(
265
269
  '/v1/project-agent-tasks/{task_id}/events',
@@ -141,6 +141,9 @@ class ProjectAgentParallelPlan(StrictModel):
141
141
 
142
142
 
143
143
  class ProjectAgentTaskSubmit(StrictModel):
144
+ # All newly submitted tasks use the quality gate, including legacy public
145
+ # entry points. Existing graph records without this field remain readable.
146
+ verification_required: Literal[True] = True
144
147
  personal_space_id: str = Field(min_length=1, max_length=128)
145
148
  personal_project_id: str = Field(min_length=1, max_length=128)
146
149
  idempotency_key: str = Field(min_length=8, max_length=256)
@@ -486,6 +489,7 @@ class ProjectAgentTaskRecord(StrictModel):
486
489
 
487
490
 
488
491
  class ProjectAgentTaskActivityCreate(StrictModel):
492
+ artifact_revision: str | None = Field(default=None, min_length=1, max_length=160)
489
493
  personal_space_id: str = Field(min_length=1, max_length=128)
490
494
  personal_project_id: str = Field(min_length=1, max_length=128)
491
495
  task_id: str = Field(min_length=1, max_length=128)
@@ -269,6 +269,20 @@ class GraphitiRuntime:
269
269
  # safe for upgraded stores as well as fresh databases.
270
270
  await self._migrate_project_agent_executor_learning_buckets()
271
271
  for query in (
272
+ 'CREATE CONSTRAINT fuli_agent_verification_id IF NOT EXISTS '
273
+ 'FOR (n:FuliAgentVerification) REQUIRE n.id IS UNIQUE',
274
+ 'CREATE CONSTRAINT fuli_agent_loan_id IF NOT EXISTS '
275
+ 'FOR (n:FuliAgentLoan) REQUIRE n.id IS UNIQUE',
276
+ 'CREATE CONSTRAINT fuli_transcript_cursor_id IF NOT EXISTS '
277
+ 'FOR (n:FuliTranscriptCursor) REQUIRE n.id IS UNIQUE',
278
+ 'CREATE CONSTRAINT fuli_conversation_id IF NOT EXISTS '
279
+ 'FOR (n:FuliConversation) REQUIRE n.id IS UNIQUE',
280
+ 'CREATE CONSTRAINT fuli_conversation_event IF NOT EXISTS '
281
+ 'FOR (n:FuliConversationEvent) REQUIRE n.id IS UNIQUE',
282
+ 'CREATE CONSTRAINT fuli_conversation_binding IF NOT EXISTS '
283
+ 'FOR (n:FuliConversationBinding) REQUIRE n.id IS UNIQUE',
284
+ 'CREATE CONSTRAINT fuli_conversation_policy IF NOT EXISTS '
285
+ 'FOR (n:FuliConversationPolicy) REQUIRE n.id IS UNIQUE',
272
286
  'CREATE CONSTRAINT fuli_project_agent_id IF NOT EXISTS '
273
287
  'FOR (n:FuliProjectAgent) REQUIRE n.id IS UNIQUE',
274
288
  'CREATE CONSTRAINT fuli_agent_attention_id IF NOT EXISTS '
@@ -2,6 +2,9 @@ from .config import Settings
2
2
  from .provider_values import graphiti_group_id, native_datetime
3
3
  from .runtime import GraphitiRuntime
4
4
  from .store_identity import StoreIdentity
5
+ from .store_agent_conversations import StoreAgentConversations
6
+ from .store_agent_loans import StoreAgentLoans
7
+ from .store_agent_verification import StoreAgentVerification
5
8
  from .store_knowledge import StoreKnowledge
6
9
  from .store_project_agent_executors import StoreProjectAgentExecutors
7
10
  from .store_project_agent_memory import StoreProjectAgentMemory
@@ -17,6 +20,9 @@ from .store_task_contexts import StoreTaskContexts
17
20
 
18
21
  class GraphStore(
19
22
  StoreIdentity,
23
+ StoreAgentConversations,
24
+ StoreAgentLoans,
25
+ StoreAgentVerification,
20
26
  StoreProjects,
21
27
  StoreProjectAgents,
22
28
  StoreProjectAgentAttention,
@@ -0,0 +1,206 @@
1
+ """Transactional conversation journal in the private graph; no LLM/embeddings."""
2
+ import base64
3
+ import gzip
4
+ import hashlib
5
+ import json
6
+ from fastapi import HTTPException
7
+ from .agent_conversation_models import ConversationPolicy
8
+ from .conversation_context import archived, pack_context
9
+ from .project_agent_access import authorize_project_agent
10
+ from .provider_values import now_utc, stable_uuid
11
+ from .store_transactions import query_store_transaction
12
+
13
+
14
+ def encode_event(event):
15
+ raw = json.dumps(event.model_dump(), ensure_ascii=False, sort_keys=True).encode()
16
+ return hashlib.sha256(raw).hexdigest(), base64.b64encode(gzip.compress(raw, mtime=0)).decode()
17
+
18
+
19
+ def decode_event(row):
20
+ return {**json.loads(gzip.decompress(base64.b64decode(row['payload']))), 'sequence': row['sequence']}
21
+
22
+
23
+ class StoreAgentConversations:
24
+ async def _conversation_scope(self, actor, request, *, write=False):
25
+ self._require_personal()
26
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer' if write else 'reader')
27
+ agent = await authorize_project_agent(self, actor, space, request.personal_project_id,
28
+ request.agent_id, require_active=write, require_memory=True)
29
+ if request.source_application is not None and request.source_application not in json.loads(agent['profile_json']).get('allowed_clients', []):
30
+ raise HTTPException(403, 'Agent is not allowed in this client')
31
+ return stable_uuid('conversation-scope', space['id'], request.personal_project_id, agent['agent_id'])
32
+
33
+ async def _conversation_task(self, actor, request):
34
+ await self.runtime.driver.execute_query(
35
+ 'MATCH (s:FuliTaskContextSession {id: $id}) SET s.conversation_serial=coalesce(s.conversation_serial,0)+1',
36
+ id=self._task_session_id(request.personal_space_id, request.source_application, request.session_id))
37
+ task = await self.get_task_context(actor, request.personal_space_id,
38
+ request.task_context_token, request.source_application)
39
+ if (task['session_id'], task['personal_project_id'], task['project_agent_id']) != (
40
+ request.session_id, request.personal_project_id, request.agent_id):
41
+ raise HTTPException(409, 'Conversation does not match the current task Agent and session')
42
+
43
+ async def _conversation_policy(self, scope):
44
+ rows, _, _ = await self.runtime.driver.execute_query(
45
+ 'MATCH (p:FuliConversationPolicy {id: $scope}) RETURN p.policy AS policy', scope=scope)
46
+ return ConversationPolicy.model_validate_json(rows[0]['policy']) if rows else ConversationPolicy()
47
+
48
+ async def write_conversation_policy(self, actor, request):
49
+ scope = await self._conversation_scope(actor, request, write=True)
50
+ await self.runtime.driver.execute_query(
51
+ 'MERGE (p:FuliConversationPolicy {id: $scope}) SET p.policy=$policy',
52
+ scope=scope, policy=request.policy.model_dump_json())
53
+ return request.policy.model_dump()
54
+
55
+ async def query_conversations(self, actor, request):
56
+ scope = await self._conversation_scope(actor, request)
57
+ policy = await self._conversation_policy(scope)
58
+ if request.mode == 'policy':
59
+ return policy.model_dump()
60
+ if request.mode == 'session':
61
+ rows, _, _ = await self.runtime.driver.execute_query(
62
+ 'OPTIONAL MATCH (b:FuliConversationBinding {id: $id}) '
63
+ 'OPTIONAL MATCH (s:FuliTranscriptCursor {id: $stream}) '
64
+ 'RETURN b.conversation_id AS conversation_id, coalesce(s.cursor,0) AS cursor, '
65
+ 'coalesce(s.initialized, s.cursor > 0, false) AS initialized',
66
+ stream=stable_uuid(request.personal_space_id, request.source_application, request.session_id or ''),
67
+ id=stable_uuid(scope, request.source_application, request.session_id or ''))
68
+ return dict(rows[0]) if rows else {'conversation_id': None, 'cursor': 0, 'initialized': False}
69
+ if request.mode == 'list':
70
+ rows, _, _ = await self.runtime.driver.execute_query(
71
+ 'MATCH (c:FuliConversation {scope: $scope}) RETURN c ORDER BY c.last_activity DESC, c.id LIMIT $limit',
72
+ scope=scope, limit=request.limit)
73
+ return {'conversations': [self._conversation_view(dict(r['c']), policy) for r in rows]}
74
+ rows, _, _ = await self.runtime.driver.execute_query(
75
+ 'MATCH (c:FuliConversation {scope: $scope, id: $id}) RETURN c', scope=scope, id=request.conversation_id)
76
+ if not rows:
77
+ raise HTTPException(404, 'Conversation not found in this Agent and project')
78
+ record = dict(rows[0]['c'])
79
+ view = self._conversation_view(record, policy)
80
+ if request.mode == 'context' and view['archived']:
81
+ return {**view, 'context': pack_context(record.get('summary', ''), [], policy.context_budget)}
82
+ order = 'DESC' if request.mode == 'context' else 'ASC'
83
+ events, _, _ = await self.runtime.driver.execute_query(
84
+ 'MATCH (c:FuliConversation {id: $id})-[:HAS_EVENT]->(e:FuliConversationEvent) '
85
+ 'WHERE e.sequence > $after RETURN e.payload AS payload, e.sequence AS sequence '
86
+ f'ORDER BY e.sequence {order} LIMIT $limit', id=record['id'], after=request.after,
87
+ limit=20 if request.mode == 'context' else request.limit)
88
+ decoded = []
89
+ total = 0
90
+ for event in events:
91
+ value = decode_event(event)
92
+ total += len(json.dumps(value, ensure_ascii=False).encode())
93
+ if total > 512 * 1024 and decoded:
94
+ break
95
+ decoded.append(value)
96
+ if request.mode == 'context':
97
+ return {**view, 'context': pack_context(record.get('summary', ''), list(reversed(decoded)), policy.context_budget)}
98
+ cursor = decoded[-1]['sequence'] if decoded else request.after
99
+ return {**view, 'events': decoded, 'next_cursor': cursor, 'has_more': cursor < record['revision']}
100
+
101
+ def _conversation_view(self, record, policy):
102
+ return {key: record.get(key) for key in ('id', 'summary', 'status', 'revision', 'last_activity')} | {
103
+ 'archived': archived(record['last_activity'], policy.idle_days),
104
+ 'policy': policy.model_dump(), 'raw_retained': True}
105
+
106
+ async def append_conversation(self, actor, request):
107
+ if request.initialize_cursor:
108
+ raise HTTPException(422, 'Initialize transcripts only at a verified task boundary')
109
+ async with query_store_transaction(self) as store:
110
+ scope = await store._conversation_scope(actor, request, write=True)
111
+ await store._conversation_task(actor, request)
112
+ policy = await store._conversation_policy(scope)
113
+ if not policy.enabled:
114
+ return {'status': 'capture_disabled'}
115
+ stream = stable_uuid(request.personal_space_id, request.source_application, request.session_id)
116
+ streams, _, _ = await store.runtime.driver.execute_query(
117
+ 'MERGE (s:FuliTranscriptCursor {id: $id}) ON CREATE SET s.cursor=0, s.owner_scope=$scope '
118
+ 'SET s.serial=coalesce(s.serial,0)+1 RETURN s.cursor AS cursor, s.owner_scope AS owner_scope, '
119
+ 'coalesce(s.initialized, s.cursor > 0, false) AS initialized', id=stream, scope=scope)
120
+ binding = stable_uuid(scope, request.source_application, request.session_id)
121
+ rows, _, _ = await store.runtime.driver.execute_query(
122
+ 'MERGE (b:FuliConversationBinding {id: $id}) '
123
+ 'ON CREATE SET b.conversation_id=$conversation_id, b.cursor=0 '
124
+ 'SET b.serial=coalesce(b.serial,0)+1 RETURN b.conversation_id AS id, b.cursor AS cursor',
125
+ id=binding, conversation_id=stable_uuid('conversation', binding))
126
+ conversation_id = rows[0]['id']
127
+ cursor = streams[0]['cursor']
128
+ if request.expected_cursor is not None and not streams[0]['initialized']:
129
+ raise HTTPException(409, 'Transcript must start at a verified task boundary')
130
+ if request.expected_cursor is not None and streams[0]['owner_scope'] not in (None, scope):
131
+ raise HTTPException(409, 'Transcript scope changed without a verified prompt boundary; do not import the old tail')
132
+ if request.expected_cursor is not None and cursor != request.expected_cursor:
133
+ raise HTTPException(409, 'Transcript cursor changed; reload before retrying')
134
+ now = now_utc().isoformat()
135
+ await store.runtime.driver.execute_query(
136
+ 'MERGE (c:FuliConversation {id: $id}) ON CREATE SET c.scope=$scope, c.revision=0, '
137
+ "c.last_activity=$now, c.summary='', c.status='reported' "
138
+ 'SET c.serial=coalesce(c.serial,0)+1', id=conversation_id, scope=scope, now=now)
139
+ new_summary = False
140
+ for event in request.events:
141
+ digest, payload = encode_event(event)
142
+ event_id = stable_uuid(conversation_id, request.source_application, request.session_id, event.event_id)
143
+ existing, _, _ = await store.runtime.driver.execute_query(
144
+ 'MATCH (e:FuliConversationEvent {id: $id}) RETURN e.digest AS digest', id=event_id)
145
+ if existing:
146
+ if existing[0]['digest'] != digest:
147
+ raise HTTPException(409, 'Conversation event ID has different content')
148
+ continue
149
+ if event.kind == 'checkpoint':
150
+ new_summary = True
151
+ await store.runtime.driver.execute_query(
152
+ 'MATCH (c:FuliConversation {id: $id}) SET c.revision=c.revision+1, c.last_activity=$now '
153
+ 'CREATE (e:FuliConversationEvent {id: $event_id, digest: $digest, payload: $payload, '
154
+ 'sequence: c.revision, source_application: $source, task_context_token: $task}) '
155
+ 'CREATE (c)-[:HAS_EVENT]->(e)', id=conversation_id, event_id=event_id,
156
+ digest=digest, payload=payload, now=now, source=request.source_application,
157
+ task=request.task_context_token)
158
+ if request.summary is not None and new_summary:
159
+ await store.runtime.driver.execute_query(
160
+ 'MATCH (c:FuliConversation {id: $id}) SET c.summary=$summary, c.status=$status',
161
+ id=conversation_id, summary=request.summary, status=request.status or 'reported')
162
+ if request.cursor is not None:
163
+ await store.runtime.driver.execute_query(
164
+ 'MATCH (s:FuliTranscriptCursor {id: $id}) SET s.cursor=$cursor, s.owner_scope=$scope', id=stream, cursor=request.cursor, scope=scope)
165
+ rows, _, _ = await store.runtime.driver.execute_query(
166
+ 'MATCH (c:FuliConversation {id: $id}) RETURN c.revision AS revision', id=conversation_id)
167
+ return {'status': 'saved', 'conversation_id': conversation_id, 'revision': rows[0]['revision']}
168
+
169
+ async def resume_conversation(self, actor, request):
170
+ async with query_store_transaction(self) as store:
171
+ scope = await store._conversation_scope(actor, request, write=True)
172
+ await store._conversation_task(actor, request)
173
+ rows, _, _ = await store.runtime.driver.execute_query(
174
+ 'MATCH (c:FuliConversation {id: $id, scope: $scope}) RETURN c.id AS id',
175
+ id=request.conversation_id, scope=scope)
176
+ if not rows:
177
+ raise HTTPException(404, 'Conversation not found in this Agent and project')
178
+ await store.runtime.driver.execute_query(
179
+ 'MERGE (b:FuliConversationBinding {id: $id}) ON CREATE SET b.cursor=0 '
180
+ 'SET b.conversation_id=$conversation_id', id=stable_uuid(scope, request.source_application, request.session_id),
181
+ conversation_id=request.conversation_id)
182
+ return {'status': 'resumed', 'conversation_id': request.conversation_id}
183
+
184
+ async def claim_conversation_boundary(self, actor, request):
185
+ if request.events or request.cursor is None or (not request.initialize_cursor and request.cursor != request.expected_cursor):
186
+ raise HTTPException(422, 'A scope boundary cannot import events or skip bytes')
187
+ async with query_store_transaction(self) as store:
188
+ scope = await store._conversation_scope(actor, request, write=True)
189
+ await store._conversation_task(actor, request)
190
+ stream = stable_uuid(request.personal_space_id, request.source_application, request.session_id)
191
+ if request.initialize_cursor:
192
+ # First hook sync has no journal entry yet. Create only after authorization;
193
+ # a failed CAS rolls this back in the same transaction.
194
+ await store.runtime.driver.execute_query(
195
+ 'MERGE (s:FuliTranscriptCursor {id:$id}) '
196
+ 'ON CREATE SET s.cursor=0, s.initialized=false', id=stream)
197
+ rows, _, _ = await store.runtime.driver.execute_query(
198
+ 'MATCH (s:FuliTranscriptCursor {id:$id}) SET s.serial=coalesce(s.serial,0)+1 '
199
+ 'WITH s WHERE s.cursor=$expected AND '
200
+ '($initialize=false OR (s.cursor=0 AND coalesce(s.initialized,false)=false)) '
201
+ 'SET s.owner_scope=$scope, s.cursor=$cursor, s.initialized=true RETURN s.cursor AS cursor',
202
+ id=stream,
203
+ cursor=request.cursor, expected=request.expected_cursor, initialize=request.initialize_cursor, scope=scope)
204
+ if not rows:
205
+ raise HTTPException(409, 'Transcript cursor changed before scope boundary')
206
+ return {'status': 'bound', 'cursor': request.cursor}