minder-cli 0.3.8__tar.gz → 0.4.1__tar.gz

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 (147) hide show
  1. {minder_cli-0.3.8 → minder_cli-0.4.1}/.gitignore +3 -1
  2. {minder_cli-0.3.8 → minder_cli-0.4.1}/PKG-INFO +1 -1
  3. {minder_cli-0.3.8 → minder_cli-0.4.1}/pyproject.toml +1 -1
  4. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/application/admin/dto.py +3 -0
  5. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/application/admin/use_cases.py +290 -83
  6. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/nodes/reasoning.py +8 -0
  7. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/nodes/workflow_planner.py +2 -2
  8. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/graph.py +3 -2
  9. minder_cli-0.4.1/src/minder/presentation/cli/commands/agent.py +187 -0
  10. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/cli/commands/ide.py +6 -2
  11. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/cli/commands/mcp.py +12 -1
  12. minder_cli-0.4.1/src/minder/presentation/cli/commands/sync.py +177 -0
  13. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/cli/commands/update.py +7 -7
  14. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/cli/main.py +11 -0
  15. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/cli/utils/git.py +3 -1
  16. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/cli/utils/version.py +15 -2
  17. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/api.py +48 -1
  18. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/memories.py +2 -3
  19. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/runtime.py +9 -0
  20. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/search.py +8 -16
  21. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/skills.py +2 -2
  22. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/prompts/__init__.py +3 -0
  23. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/resources/__init__.py +14 -10
  24. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/graph.py +267 -40
  25. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/interfaces.py +30 -1
  26. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/graph.py +169 -82
  27. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/ingest.py +17 -10
  28. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/memory.py +14 -7
  29. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/query.py +24 -1
  30. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/repo_scanner.py +169 -66
  31. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/skills.py +5 -10
  32. minder_cli-0.3.8/src/minder/presentation/cli/commands/sync.py +0 -97
  33. {minder_cli-0.3.8 → minder_cli-0.4.1}/LICENSE +0 -0
  34. {minder_cli-0.3.8 → minder_cli-0.4.1}/README-pypi.md +0 -0
  35. {minder_cli-0.3.8 → minder_cli-0.4.1}/README.md +0 -0
  36. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/__init__.py +0 -0
  37. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/api/routers/prompts.py +0 -0
  38. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/application/__init__.py +0 -0
  39. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/application/admin/__init__.py +0 -0
  40. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/application/admin/jobs.py +0 -0
  41. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/auth/__init__.py +0 -0
  42. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/auth/context.py +0 -0
  43. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/auth/middleware.py +0 -0
  44. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/auth/principal.py +0 -0
  45. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/auth/rate_limiter.py +0 -0
  46. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/auth/rbac.py +0 -0
  47. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/auth/service.py +0 -0
  48. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/bootstrap/__init__.py +0 -0
  49. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/bootstrap/providers.py +0 -0
  50. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/bootstrap/transport.py +0 -0
  51. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/cache/__init__.py +0 -0
  52. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/cache/providers.py +0 -0
  53. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/chunking/__init__.py +0 -0
  54. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/chunking/code_splitter.py +0 -0
  55. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/chunking/splitter.py +0 -0
  56. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/cli.py +0 -0
  57. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/config.py +0 -0
  58. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/continuity.py +0 -0
  59. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/dev.py +0 -0
  60. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/embedding/__init__.py +0 -0
  61. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/embedding/base.py +0 -0
  62. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/embedding/local.py +0 -0
  63. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/embedding/openai.py +0 -0
  64. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/__init__.py +0 -0
  65. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/edges.py +0 -0
  66. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/executor.py +0 -0
  67. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/graph.py +0 -0
  68. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/nodes/__init__.py +0 -0
  69. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/nodes/evaluator.py +0 -0
  70. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/nodes/guard.py +0 -0
  71. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/nodes/llm.py +0 -0
  72. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/nodes/planning.py +0 -0
  73. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/nodes/reranker.py +0 -0
  74. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/nodes/retriever.py +0 -0
  75. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/nodes/verification.py +0 -0
  76. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/runtime.py +0 -0
  77. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/graph/state.py +0 -0
  78. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/llm/__init__.py +0 -0
  79. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/llm/base.py +0 -0
  80. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/llm/factory.py +0 -0
  81. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/llm/litert.py +0 -0
  82. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/llm/openai.py +0 -0
  83. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/__init__.py +0 -0
  84. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/base.py +0 -0
  85. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/client.py +0 -0
  86. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/document.py +0 -0
  87. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/error.py +0 -0
  88. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/history.py +0 -0
  89. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/job.py +0 -0
  90. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/prompt.py +0 -0
  91. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/repository.py +0 -0
  92. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/rule.py +0 -0
  93. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/session.py +0 -0
  94. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/skill.py +0 -0
  95. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/user.py +0 -0
  96. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/models/workflow.py +0 -0
  97. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/observability/__init__.py +0 -0
  98. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/observability/audit.py +0 -0
  99. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/observability/logging.py +0 -0
  100. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/observability/metrics.py +0 -0
  101. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/observability/tracing.py +0 -0
  102. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/__init__.py +0 -0
  103. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/cli/__init__.py +0 -0
  104. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/cli/commands/auth.py +0 -0
  105. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/cli/utils/common.py +0 -0
  106. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/cli/utils/config.py +0 -0
  107. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/__init__.py +0 -0
  108. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/__init__.py +0 -0
  109. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/context.py +0 -0
  110. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/dashboard.py +0 -0
  111. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/jobs.py +0 -0
  112. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/prompts.py +0 -0
  113. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/presentation/http/admin/routes.py +0 -0
  114. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/prompts/formatter.py +0 -0
  115. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/retrieval/__init__.py +0 -0
  116. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/retrieval/hybrid.py +0 -0
  117. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/retrieval/mmr.py +0 -0
  118. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/retrieval/multi_hop.py +0 -0
  119. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/runtime.py +0 -0
  120. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/server.py +0 -0
  121. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/__init__.py +0 -0
  122. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/document.py +0 -0
  123. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/error.py +0 -0
  124. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/feedback.py +0 -0
  125. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/history.py +0 -0
  126. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/milvus/__init__.py +0 -0
  127. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/milvus/client.py +0 -0
  128. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/milvus/collections.py +0 -0
  129. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/milvus/vector_store.py +0 -0
  130. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/mongodb/__init__.py +0 -0
  131. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/mongodb/client.py +0 -0
  132. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/mongodb/indexes.py +0 -0
  133. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/mongodb/operational_store.py +0 -0
  134. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/relational.py +0 -0
  135. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/repo_state.py +0 -0
  136. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/rule.py +0 -0
  137. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/store/vector.py +0 -0
  138. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/__init__.py +0 -0
  139. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/auth.py +0 -0
  140. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/registry.py +0 -0
  141. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/search.py +0 -0
  142. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/session.py +0 -0
  143. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/tools/workflow.py +0 -0
  144. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/transport/__init__.py +0 -0
  145. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/transport/base.py +0 -0
  146. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/transport/sse.py +0 -0
  147. {minder_cli-0.3.8 → minder_cli-0.4.1}/src/minder/transport/stdio.py +0 -0
@@ -9,4 +9,6 @@ __pycache__
9
9
  ref
10
10
  .mcp.json
11
11
  .vscode/mcp.json
12
- default.profraw
12
+ default.profraw
13
+ node_modules
14
+ .astro
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: minder-cli
3
- Version: 0.3.8
3
+ Version: 0.4.1
4
4
  Summary: Minder CLI is the command-line interface for the Minder self-hosted MCP platform.
5
5
  Project-URL: Homepage, https://github.com/hiimtrung/minder
6
6
  Project-URL: Repository, https://github.com/hiimtrung/minder
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "minder-cli"
7
- version = "0.3.8"
7
+ version = "0.4.1"
8
8
  description = "Minder CLI is the command-line interface for the Minder self-hosted MCP platform."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -276,6 +276,7 @@ class ClientRepositoryResolveRequest(BaseModel):
276
276
  class ClientRepositoryResolvePayload(TypedDict):
277
277
  repository: RepositoryPayload
278
278
  created: bool
279
+ last_sync: dict[str, Any] | None
279
280
 
280
281
 
281
282
  class GraphSyncNodeRefRequest(BaseModel):
@@ -314,7 +315,9 @@ class GraphSyncRequest(BaseModel):
314
315
  repo_path: str | None = None
315
316
  branch: str | None = None
316
317
  diff_base: str | None = None
318
+ changed_files: list[str] = Field(default_factory=list)
317
319
  deleted_files: list[str] = Field(default_factory=list)
320
+ commit_hash: str | None = None
318
321
  sync_metadata: dict[str, Any] = Field(default_factory=dict)
319
322
  nodes: list[GraphSyncNodeRequest] = Field(default_factory=list)
320
323
  edges: list[GraphSyncEdgeRequest] = Field(default_factory=list)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import uuid
4
5
  from collections import Counter
5
6
  from datetime import UTC, datetime
@@ -78,6 +79,8 @@ DASHBOARD_TOOL_SCOPE_PRESETS: dict[str, list[str]] = {
78
79
  ],
79
80
  }
80
81
 
82
+ logger = logging.getLogger(__name__)
83
+
81
84
 
82
85
  class AdminConsoleUseCases:
83
86
  def __init__(
@@ -743,7 +746,7 @@ class AdminConsoleUseCases:
743
746
  )
744
747
  if existing_remote != normalized_url:
745
748
  updates["repo_url"] = normalized_url
746
- if str(getattr(repository, "state_path", "") or "") != state_path:
749
+ if not str(getattr(repository, "state_path", "") or "").strip() and state_path:
747
750
  updates["state_path"] = state_path
748
751
  if (
749
752
  normalized_branch
@@ -760,6 +763,7 @@ class AdminConsoleUseCases:
760
763
  return {
761
764
  "repository": self.serialize_repository(repository),
762
765
  "created": created,
766
+ "last_sync": self._repository_last_sync(repository),
763
767
  }
764
768
 
765
769
  @staticmethod
@@ -810,9 +814,33 @@ class AdminConsoleUseCases:
810
814
  deleted_nodes = 0
811
815
  nodes_upserted = 0
812
816
  edges_upserted = 0
813
-
817
+
818
+ # --- Check for redundant sync using commit_hash ---
819
+ relationships = dict(getattr(repository, "relationships", {}) or {})
820
+ graph_sync = dict(relationships.get("graph_sync", {}) or {})
821
+ last_sync = dict(graph_sync.get("last_sync", {}) or {})
822
+
823
+ if (
824
+ payload.commit_hash
825
+ and payload.commit_hash == last_sync.get("commit_hash")
826
+ and not payload.nodes
827
+ and not payload.edges
828
+ and not payload.deleted_files
829
+ ):
830
+ return {
831
+ "repo_id": str(repo_id),
832
+ "repository_name": repo_name,
833
+ "payload_version": payload.payload_version,
834
+ "source": payload.source,
835
+ "branch": branch,
836
+ "deleted_nodes": 0,
837
+ "nodes_upserted": 0,
838
+ "edges_upserted": 0,
839
+ "accepted_at": accepted_at,
840
+ }
841
+
814
842
  # --- Scoped deletion: prune stale nodes for changed/deleted files ---
815
- changed_files = payload.sync_metadata.get("changed_files", [])
843
+ changed_files = payload.changed_files
816
844
  paths_to_prune: set[str] = set(payload.deleted_files)
817
845
  if isinstance(changed_files, list):
818
846
  paths_to_prune.update(
@@ -835,7 +863,7 @@ class AdminConsoleUseCases:
835
863
  )
836
864
  else:
837
865
  for graph_node in await self._graph_store.list_nodes():
838
- metadata = dict(getattr(graph_node, "node_metadata", {}) or {})
866
+ metadata = dict(graph_node.extra_metadata or {})
839
867
  if metadata.get("repo_id") != str(repo_id):
840
868
  continue
841
869
  if branch is not None and metadata.get("branch") not in {
@@ -851,6 +879,13 @@ class AdminConsoleUseCases:
851
879
  # --- Upsert nodes with proper repo/branch scope (v2) ---
852
880
  _branch = branch or ""
853
881
  _repo_id_str = str(repo_id)
882
+
883
+ # We strip large collections from sync_metadata before broadcasting to nodes
884
+ _filtered_sync_meta = {
885
+ k: v for k, v in payload.sync_metadata.items()
886
+ if k not in {"changed_files", "deleted_files"}
887
+ }
888
+
854
889
  _common_meta = {
855
890
  "repo_id": _repo_id_str,
856
891
  "repository_name": repo_name,
@@ -860,20 +895,8 @@ class AdminConsoleUseCases:
860
895
  "branch": _branch,
861
896
  "repo_path": payload.repo_path,
862
897
  "diff_base": payload.diff_base,
863
- **payload.sync_metadata,
898
+ **_filtered_sync_meta,
864
899
  }
865
-
866
- for node in payload.nodes:
867
- persisted = await self._graph_store.upsert_node(
868
- node.node_type,
869
- node.name,
870
- metadata={**_common_meta, **node.metadata},
871
- repo_id=_repo_id_str,
872
- branch=_branch,
873
- )
874
- node_ids[(node.node_type, node.name)] = persisted.id
875
- nodes_upserted += 1
876
-
877
900
  _edge_common_meta = {
878
901
  "repo_id": _repo_id_str,
879
902
  "repository_name": repo_name,
@@ -884,40 +907,54 @@ class AdminConsoleUseCases:
884
907
  "repo_path": payload.repo_path,
885
908
  }
886
909
 
910
+ # --- Bulk upsert nodes ---
911
+ # Use a dict to deduplicate nodes by (type, name)
912
+ deduped_nodes: dict[tuple[str, str], dict[str, Any]] = {}
913
+
914
+ for node in payload.nodes:
915
+ key = (node.node_type, node.name)
916
+ deduped_nodes[key] = {
917
+ "node_type": node.node_type,
918
+ "name": node.name,
919
+ "metadata": {**_common_meta, **node.metadata},
920
+ }
921
+
922
+ # Also need to collect nodes mentioned in edges that might not be in payload.nodes
887
923
  for edge in payload.edges:
888
- source_key = (edge.source.node_type, edge.source.name)
889
- target_key = (edge.target.node_type, edge.target.name)
890
-
891
- if source_key not in node_ids:
892
- source_node = await self._graph_store.upsert_node(
893
- edge.source.node_type,
894
- edge.source.name,
895
- metadata=_edge_common_meta,
896
- repo_id=_repo_id_str,
897
- branch=_branch,
898
- )
899
- node_ids[source_key] = source_node.id
900
- nodes_upserted += 1
901
-
902
- if target_key not in node_ids:
903
- target_node = await self._graph_store.upsert_node(
904
- edge.target.node_type,
905
- edge.target.name,
906
- metadata=_edge_common_meta,
907
- repo_id=_repo_id_str,
908
- branch=_branch,
909
- )
910
- node_ids[target_key] = target_node.id
911
- nodes_upserted += 1
912
-
913
- await self._graph_store.upsert_edge(
914
- source_id=node_ids[source_key],
915
- target_id=node_ids[target_key],
916
- relation=edge.relation,
917
- weight=edge.weight,
918
- repo_id=_repo_id_str,
919
- )
920
- edges_upserted += 1
924
+ for side in [edge.source, edge.target]:
925
+ key = (side.node_type, side.name)
926
+ if key not in deduped_nodes:
927
+ deduped_nodes[key] = {
928
+ "node_type": side.node_type,
929
+ "name": side.name,
930
+ "metadata": _edge_common_meta,
931
+ }
932
+
933
+ node_ids = await self._graph_store.bulk_upsert_nodes(
934
+ list(deduped_nodes.values()),
935
+ repo_id=_repo_id_str,
936
+ branch=_branch,
937
+ )
938
+ nodes_upserted = len(deduped_nodes)
939
+
940
+ # --- Bulk upsert edges ---
941
+ deduped_edges: dict[tuple[uuid.UUID, uuid.UUID, str], dict[str, Any]] = {}
942
+ for edge in payload.edges:
943
+ source_id = node_ids.get((edge.source.node_type, edge.source.name))
944
+ target_id = node_ids.get((edge.target.node_type, edge.target.name))
945
+ if source_id and target_id:
946
+ edge_key = (source_id, target_id, edge.relation)
947
+ deduped_edges[edge_key] = {
948
+ "source_id": source_id,
949
+ "target_id": target_id,
950
+ "relation": edge.relation,
951
+ "weight": edge.weight,
952
+ }
953
+
954
+ edges_upserted = await self._graph_store.bulk_upsert_edges(
955
+ list(deduped_edges.values()),
956
+ repo_id=_repo_id_str,
957
+ )
921
958
 
922
959
  # --- Update repository: tracked_branches + graph_sync metadata ---
923
960
  relationships = dict(getattr(repository, "relationships", {}) or {})
@@ -929,12 +966,12 @@ class AdminConsoleUseCases:
929
966
  "repo_remote": repo_remote,
930
967
  "diff_base": payload.diff_base,
931
968
  "deleted_files": payload.deleted_files,
969
+ "commit_hash": payload.commit_hash,
932
970
  "deleted_nodes": deleted_nodes,
933
971
  "nodes_upserted": nodes_upserted,
934
972
  "edges_upserted": edges_upserted,
935
973
  "accepted_at": accepted_at,
936
974
  }
937
- graph_sync = dict(relationships.get("graph_sync", {}) or {})
938
975
  graph_sync["last_sync"] = graph_sync_state
939
976
  graph_sync.update(graph_sync_state)
940
977
  branch_registry = dict(graph_sync.get("branches", {}) or {})
@@ -983,6 +1020,9 @@ class AdminConsoleUseCases:
983
1020
  tracked_branches=raw_branches,
984
1021
  )
985
1022
 
1023
+ # Auto-prune stale branches that are no longer tracked
1024
+ await self.prune_repository_stale_data(repo_id)
1025
+
986
1026
  return {
987
1027
  "repo_id": str(repo_id),
988
1028
  "repository_name": repo_name,
@@ -1031,35 +1071,82 @@ class AdminConsoleUseCases:
1031
1071
 
1032
1072
  repo_nodes = await self._repository_graph_nodes(repository, branch=branch)
1033
1073
  counts = Counter(str(getattr(node, "node_type", "")) for node in repo_nodes)
1034
- repo_node_ids = {str(getattr(node, "id")) for node in repo_nodes}
1035
- services = [
1036
- node
1037
- for node in repo_nodes
1038
- if str(getattr(node, "node_type", "")) == "service"
1039
- ]
1074
+ # 1. Fetch all edges for the repo
1075
+ repo_edges = await self._graph_store.list_edges_by_scope(repo_id=str(repo_id))
1076
+
1077
+ # 2. Build map of nodes and containment map (parent map)
1078
+ node_id_to_node = {str(getattr(node, "id")): node for node in repo_nodes}
1079
+ parent_map: dict[str, str] = {} # child_id -> parent_id
1080
+ for edge in repo_edges:
1081
+ if edge.relation == "contains":
1082
+ parent_map[str(edge.target_id)] = str(edge.source_id)
1083
+
1084
+ # 3. Identify dependency relations
1085
+ dependency_relations = {"depends_on", "uses_external_service", "calls", "imports"}
1086
+
1087
+ # 4. Find all dependency edges and group by high-level owner
1088
+ # owner_id -> set of target info
1089
+ owner_to_deps: dict[str, set[tuple[str, str, str]]] = {}
1090
+
1091
+ high_level_types = {"service", "repository", "module", "controller"}
1092
+
1093
+ for edge in repo_edges:
1094
+ if edge.relation not in dependency_relations:
1095
+ continue
1096
+
1097
+ source_id = str(edge.source_id)
1098
+ target_id = str(edge.target_id)
1099
+
1100
+ # Find high-level owner for the source node
1101
+ curr_id = source_id
1102
+ owner_node = None
1103
+
1104
+ # Search upwards for a high-level owner
1105
+ visited = {curr_id}
1106
+ while curr_id in node_id_to_node:
1107
+ node = node_id_to_node[curr_id]
1108
+ if str(getattr(node, "node_type", "")) in high_level_types:
1109
+ owner_node = node
1110
+ break
1111
+
1112
+ parent_id = parent_map.get(curr_id)
1113
+ if not parent_id or parent_id in visited:
1114
+ break
1115
+ curr_id = parent_id
1116
+ visited.add(curr_id)
1117
+
1118
+ if not owner_node:
1119
+ continue
1120
+
1121
+ # Get target info
1122
+ target_node = node_id_to_node.get(target_id)
1123
+ if not target_node:
1124
+ continue
1125
+
1126
+ owner_id = str(owner_node.id)
1127
+ if owner_id not in owner_to_deps:
1128
+ owner_to_deps[owner_id] = set()
1129
+
1130
+ owner_to_deps[owner_id].add((
1131
+ target_id,
1132
+ str(getattr(target_node, "name", "")),
1133
+ str(getattr(target_node, "node_type", "")),
1134
+ ))
1135
+
1136
+ # 5. Format the results
1040
1137
  dependencies: list[dict[str, Any]] = []
1041
- for service in services:
1042
- neighbors = await self._graph_store.get_neighbors(
1043
- getattr(service, "id"),
1044
- direction="out",
1045
- relation="depends_on",
1046
- )
1047
- targets = [
1048
- {
1049
- "id": str(getattr(neighbor, "id")),
1050
- "name": str(getattr(neighbor, "name", "")),
1051
- "node_type": str(getattr(neighbor, "node_type", "")),
1052
- }
1053
- for neighbor in neighbors
1054
- if str(getattr(neighbor, "id")) in repo_node_ids
1138
+ for owner_id, targets in owner_to_deps.items():
1139
+ owner_node = node_id_to_node[owner_id]
1140
+ depends_on_items: list[dict[str, str]] = [
1141
+ {"id": tid, "name": tname, "node_type": ttype}
1142
+ for tid, tname, ttype in targets
1055
1143
  ]
1056
- if targets:
1057
- dependencies.append(
1058
- {
1059
- "service": str(getattr(service, "name", "")),
1060
- "depends_on": sorted(targets, key=lambda item: item["name"]),
1061
- }
1062
- )
1144
+ dependencies.append({
1145
+ "service": str(getattr(owner_node, "name", "")),
1146
+ "source_type": str(getattr(owner_node, "node_type", "")),
1147
+ "depends_on": sorted(depends_on_items, key=lambda item: item["name"]),
1148
+ })
1149
+ dependencies.sort(key=lambda item: str(item["service"]))
1063
1150
 
1064
1151
  return {
1065
1152
  "repository": repository_payload,
@@ -1071,13 +1158,19 @@ class AdminConsoleUseCases:
1071
1158
  "node_count": len(repo_nodes),
1072
1159
  "counts_by_type": dict(counts),
1073
1160
  "routes": self._serialize_repo_graph_nodes(
1074
- repo_nodes, allowed_types={"route"}, limit=12
1161
+ repo_nodes,
1162
+ allowed_types={"route", "api_endpoint", "websocket_endpoint"},
1163
+ limit=50
1075
1164
  ),
1076
1165
  "todos": self._serialize_repo_graph_nodes(
1077
- repo_nodes, allowed_types={"todo"}, limit=12
1166
+ repo_nodes,
1167
+ allowed_types={"todo"},
1168
+ limit=50
1078
1169
  ),
1079
1170
  "external_services": self._serialize_repo_graph_nodes(
1080
- repo_nodes, allowed_types={"external_service_api"}, limit=12
1171
+ repo_nodes,
1172
+ allowed_types={"external_service_api", "external_service"},
1173
+ limit=50
1081
1174
  ),
1082
1175
  "dependencies": dependencies,
1083
1176
  }
@@ -1087,6 +1180,8 @@ class AdminConsoleUseCases:
1087
1180
  *,
1088
1181
  repo_id: uuid.UUID,
1089
1182
  branch: str | None = None,
1183
+ node_types: list[str] | None = None,
1184
+ limit: int | None = 1000,
1090
1185
  ) -> RepositoryGraphMapPayload:
1091
1186
  repository = await self._store.get_repository_by_id(repo_id)
1092
1187
  if repository is None:
@@ -1124,7 +1219,41 @@ class AdminConsoleUseCases:
1124
1219
  repo_name=getattr(repository, "repo_name", None),
1125
1220
  repo_path=self._repository_root_path(repository),
1126
1221
  branch=effective_branch,
1222
+ node_types=node_types,
1127
1223
  )
1224
+
1225
+ total_nodes = len(repo_nodes)
1226
+ total_edges = len(repo_edges)
1227
+
1228
+ # If too many nodes, we limit the return set to prevent browser freeze
1229
+ # We prioritize nodes by their 'importance' (node_type)
1230
+ if limit and len(repo_nodes) > limit:
1231
+ # Simple heuristic: prioritize non-file nodes first (higher level abstraction)
1232
+ type_priority = {
1233
+ "repository": 0,
1234
+ "service": 1,
1235
+ "module": 2,
1236
+ "controller": 3,
1237
+ "route": 4,
1238
+ "api_endpoint": 5,
1239
+ "websocket_endpoint": 6,
1240
+ "mq_topic": 7,
1241
+ "external_service_api": 8,
1242
+ "workflow": 9,
1243
+ "folder": 10,
1244
+ "file": 11,
1245
+ "todo": 12,
1246
+ "class": 20,
1247
+ "interface": 21,
1248
+ "abstract_class": 22,
1249
+ "function": 23,
1250
+ }
1251
+ repo_nodes.sort(key=lambda n: type_priority.get(getattr(n, "node_type", ""), 15))
1252
+ repo_nodes = repo_nodes[:limit]
1253
+
1254
+ # Filter edges to only those connecting remaining nodes
1255
+ node_ids = {n.id for n in repo_nodes}
1256
+ repo_edges = [e for e in repo_edges if e.source_id in node_ids and e.target_id in node_ids]
1128
1257
  node_counts = Counter(
1129
1258
  str(getattr(node, "node_type", "")) for node in repo_nodes
1130
1259
  )
@@ -1139,14 +1268,92 @@ class AdminConsoleUseCases:
1139
1268
  "branch_links": branch_links["links"],
1140
1269
  "nodes": [self._serialize_graph_node(node) for node in repo_nodes],
1141
1270
  "edges": [self._serialize_graph_edge(edge) for edge in repo_edges],
1271
+ "summary": {
1272
+ "node_count": total_nodes,
1273
+ "edge_count": total_edges,
1274
+ "returned_node_count": len(repo_nodes),
1275
+ "returned_edge_count": len(repo_edges),
1276
+ "counts_by_type": dict(node_counts),
1277
+ "counts_by_relation": dict(relation_counts),
1278
+ },
1279
+ }
1280
+
1281
+ async def get_repository_node_neighborhood(
1282
+ self,
1283
+ *,
1284
+ repo_id: uuid.UUID,
1285
+ node_id: uuid.UUID,
1286
+ depth: int = 4,
1287
+ limit: int = 200,
1288
+ ) -> RepositoryGraphMapPayload:
1289
+ """Fetch a subgraph around a specific node with limited depth/count."""
1290
+ repository = await self._store.get_repository_by_id(repo_id)
1291
+ if repository is None:
1292
+ raise LookupError("Repository not found")
1293
+
1294
+ if self._graph_store is None:
1295
+ raise RuntimeError("Graph store is not configured")
1296
+
1297
+ repo_nodes, repo_edges = await self._graph_store.get_neighborhood(
1298
+ node_id=node_id,
1299
+ max_depth=depth,
1300
+ max_nodes=limit,
1301
+ )
1302
+
1303
+ repository_payload = self.serialize_repository(repository)
1304
+
1305
+ # Calculate summary
1306
+ node_counts = Counter(
1307
+ str(getattr(node, "node_type", "")) for node in repo_nodes
1308
+ )
1309
+ relation_counts = Counter(
1310
+ str(getattr(edge, "relation", "")) for edge in repo_edges
1311
+ )
1312
+
1313
+ return {
1314
+ "repository": repository_payload,
1315
+ "graph_available": bool(repo_nodes),
1316
+ "branch": getattr(repo_nodes[0], "branch", None) if repo_nodes else None,
1317
+ "branch_state": None,
1318
+ "branch_links": [],
1319
+ "nodes": [self._serialize_graph_node(node) for node in repo_nodes],
1320
+ "edges": [self._serialize_graph_edge(edge) for edge in repo_edges],
1142
1321
  "summary": {
1143
1322
  "node_count": len(repo_nodes),
1144
1323
  "edge_count": len(repo_edges),
1324
+ "returned_node_count": len(repo_nodes),
1325
+ "returned_edge_count": len(repo_edges),
1145
1326
  "counts_by_type": dict(node_counts),
1146
1327
  "counts_by_relation": dict(relation_counts),
1147
1328
  },
1148
1329
  }
1149
1330
 
1331
+ async def prune_repository_stale_data(self, repo_id: uuid.UUID) -> dict[str, Any]:
1332
+ """Delete graph data for branches that are no longer tracked."""
1333
+ if self._graph_store is None:
1334
+ return {"deleted": 0}
1335
+
1336
+ repository = await self._store.get_repository_by_id(repo_id)
1337
+ if not repository:
1338
+ return {"deleted": 0}
1339
+
1340
+ tracked = set(getattr(repository, "tracked_branches", []) or [])
1341
+ if not tracked:
1342
+ return {"deleted": 0}
1343
+
1344
+ repo_id_str = str(repo_id)
1345
+ branches_in_graph = await self._graph_store.list_repo_branches(repo_id_str)
1346
+
1347
+ deleted_total = 0
1348
+ for branch in branches_in_graph:
1349
+ if branch not in tracked:
1350
+ logger.info(f"Pruning stale branch data: {repo_id_str}/{branch}")
1351
+ deleted_total += await self._graph_store.delete_nodes_by_scope(
1352
+ repo_id=repo_id_str, branch=branch
1353
+ )
1354
+
1355
+ return {"deleted": deleted_total}
1356
+
1150
1357
  async def search_repository_graph(
1151
1358
  self,
1152
1359
  *,
@@ -1599,7 +1806,7 @@ class AdminConsoleUseCases:
1599
1806
 
1600
1807
  @staticmethod
1601
1808
  def _serialize_graph_node(node: Any) -> RepositoryGraphNodePayload:
1602
- metadata = dict(getattr(node, "node_metadata", {}) or {})
1809
+ metadata = dict(node.extra_metadata or {})
1603
1810
  return {
1604
1811
  "id": str(getattr(node, "id")),
1605
1812
  "node_type": str(getattr(node, "node_type", "")),
@@ -1632,7 +1839,7 @@ class AdminConsoleUseCases:
1632
1839
  filtered.sort(
1633
1840
  key=lambda node: (
1634
1841
  str(getattr(node, "node_type", "")),
1635
- str(dict(getattr(node, "node_metadata", {}) or {}).get("path", "")),
1842
+ str((node.extra_metadata or {}).get("path", "")),
1636
1843
  str(getattr(node, "name", "")),
1637
1844
  )
1638
1845
  )
@@ -78,6 +78,14 @@ class ReasoningNode:
78
78
  else "No repository context found."
79
79
  ),
80
80
  "correction_required": retry_reason,
81
+ "chat_history": (
82
+ "\n".join(
83
+ f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content')}"
84
+ for m in (state.chat_history or [])
85
+ )
86
+ if state.chat_history
87
+ else "No conversation history."
88
+ ),
81
89
  },
82
90
  defaults=prompt_defaults,
83
91
  )
@@ -174,8 +174,8 @@ class WorkflowPlannerNode:
174
174
  for n in service_nodes
175
175
  if n.name == repo_name
176
176
  or (
177
- hasattr(n, "node_metadata")
178
- and n.node_metadata.get("path", "").startswith(repo_path)
177
+ hasattr(n, "extra_metadata")
178
+ and (n.extra_metadata or {}).get("path", "").startswith(repo_path)
179
179
  )
180
180
  ]
181
181
 
@@ -20,6 +20,7 @@ from typing import Any
20
20
 
21
21
  from pydantic import Field
22
22
  from sqlalchemy import DateTime, Float, JSON, String, UUID, UniqueConstraint, func
23
+ from sqlalchemy.ext.mutable import MutableDict
23
24
  from sqlalchemy.orm import Mapped, mapped_column
24
25
 
25
26
  from .base import Base, BaseModelMeta
@@ -36,7 +37,7 @@ class GraphNodeSchema(BaseModelMeta):
36
37
  branch: str = ""
37
38
  node_type: str # module | file | service | owner | route | api_endpoint | websocket_endpoint | mq_topic | …
38
39
  name: str
39
- metadata: dict[str, Any] = Field(default_factory=dict)
40
+ extra_metadata: dict[str, Any] = Field(default_factory=dict)
40
41
  created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
41
42
 
42
43
 
@@ -80,7 +81,7 @@ class GraphNode(Base):
80
81
  branch: Mapped[str] = mapped_column(String, index=True, default="", server_default="")
81
82
  node_type: Mapped[str] = mapped_column(String, index=True)
82
83
  name: Mapped[str] = mapped_column(String, index=True)
83
- node_metadata: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
84
+ extra_metadata: Mapped[dict] = mapped_column("metadata", MutableDict.as_mutable(JSON), default=dict)
84
85
  created_at: Mapped[datetime] = mapped_column(
85
86
  DateTime(timezone=True), server_default=func.now()
86
87
  )