@agxnte/reidx 2.0.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 (135) hide show
  1. package/README.md +724 -0
  2. package/bin/reidx.js +33 -0
  3. package/package.json +40 -0
  4. package/pyproject.toml +45 -0
  5. package/scripts/find-python.js +24 -0
  6. package/scripts/postinstall.js +35 -0
  7. package/src/reidx/__init__.py +3 -0
  8. package/src/reidx/__main__.py +9 -0
  9. package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
  10. package/src/reidx/app/__init__.py +3 -0
  11. package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
  13. package/src/reidx/app/commands.py +221 -0
  14. package/src/reidx/automation/__init__.py +3 -0
  15. package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
  17. package/src/reidx/automation/exec.py +35 -0
  18. package/src/reidx/config/__init__.py +10 -0
  19. package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
  20. package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
  21. package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
  22. package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
  23. package/src/reidx/config/loader.py +104 -0
  24. package/src/reidx/config/models.py +49 -0
  25. package/src/reidx/config/settings.py +127 -0
  26. package/src/reidx/deepreid/__init__.py +8 -0
  27. package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
  29. package/src/reidx/deepreid/pipeline.py +221 -0
  30. package/src/reidx/diagnostics/__init__.py +0 -0
  31. package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
  33. package/src/reidx/diagnostics/logger.py +56 -0
  34. package/src/reidx/goals/__init__.py +21 -0
  35. package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
  36. package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
  37. package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
  38. package/src/reidx/goals/models.py +101 -0
  39. package/src/reidx/goals/store.py +233 -0
  40. package/src/reidx/integrations/__init__.py +3 -0
  41. package/src/reidx/integrations/mcp.py +70 -0
  42. package/src/reidx/nyx/__init__.py +3 -0
  43. package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
  45. package/src/reidx/nyx/persona.py +35 -0
  46. package/src/reidx/policy/__init__.py +15 -0
  47. package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
  48. package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
  49. package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
  50. package/src/reidx/policy/engine.py +101 -0
  51. package/src/reidx/policy/models.py +35 -0
  52. package/src/reidx/provider/__init__.py +16 -0
  53. package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
  55. package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
  56. package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
  57. package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
  58. package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
  59. package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
  60. package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
  61. package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
  62. package/src/reidx/provider/_http.py +29 -0
  63. package/src/reidx/provider/anthropic.py +176 -0
  64. package/src/reidx/provider/base.py +50 -0
  65. package/src/reidx/provider/ollama.py +109 -0
  66. package/src/reidx/provider/openai.py +144 -0
  67. package/src/reidx/provider/registry.py +88 -0
  68. package/src/reidx/provider/store.py +141 -0
  69. package/src/reidx/provider/stub.py +60 -0
  70. package/src/reidx/provider_manager/__init__.py +40 -0
  71. package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
  72. package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
  73. package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
  74. package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
  75. package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
  76. package/src/reidx/provider_manager/catalog.py +330 -0
  77. package/src/reidx/provider_manager/database.py +234 -0
  78. package/src/reidx/provider_manager/keychain.py +102 -0
  79. package/src/reidx/provider_manager/palette.py +831 -0
  80. package/src/reidx/runtime/__init__.py +5 -0
  81. package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  82. package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
  83. package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
  84. package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
  85. package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
  86. package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
  87. package/src/reidx/runtime/agent.py +198 -0
  88. package/src/reidx/runtime/orchestrator.py +244 -0
  89. package/src/reidx/runtime/reasoning.py +77 -0
  90. package/src/reidx/runtime/state.py +32 -0
  91. package/src/reidx/runtime/subagent.py +174 -0
  92. package/src/reidx/session/__init__.py +4 -0
  93. package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
  94. package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
  95. package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
  96. package/src/reidx/session/models.py +43 -0
  97. package/src/reidx/session/store.py +101 -0
  98. package/src/reidx/tasks/__init__.py +4 -0
  99. package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
  100. package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
  101. package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
  102. package/src/reidx/tasks/models.py +44 -0
  103. package/src/reidx/tasks/store.py +86 -0
  104. package/src/reidx/tools/__init__.py +44 -0
  105. package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  106. package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
  107. package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  108. package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
  109. package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
  110. package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
  111. package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  112. package/src/reidx/tools/base.py +84 -0
  113. package/src/reidx/tools/file_tools.py +222 -0
  114. package/src/reidx/tools/registry.py +45 -0
  115. package/src/reidx/tools/shell_tool.py +75 -0
  116. package/src/reidx/tools/spawn_agent.py +185 -0
  117. package/src/reidx/tools/web_tools.py +216 -0
  118. package/src/reidx/ui/__init__.py +41 -0
  119. package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
  120. package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
  121. package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
  122. package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
  123. package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
  124. package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
  125. package/src/reidx/ui/app.py +1343 -0
  126. package/src/reidx/ui/commands.py +663 -0
  127. package/src/reidx/ui/render.py +517 -0
  128. package/src/reidx/ui/repl.py +14 -0
  129. package/src/reidx/ui/theme.py +126 -0
  130. package/src/reidx/workflows/__init__.py +4 -0
  131. package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
  132. package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
  133. package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
  134. package/src/reidx/workflows/models.py +19 -0
  135. package/src/reidx/workflows/store.py +56 -0
@@ -0,0 +1,174 @@
1
+ """Subagent runtime: lifecycle tracking + event bus for spawned child agents.
2
+
3
+ The `spawn_agent` tool constructs child `Agent`s (each with its own scoped
4
+ `PolicyEngine` and tool allowlist) and reports their lifecycle here. The TUI
5
+ subscribes to the manager so the "active agents" panel below the status line
6
+ knows what to render — one row per live subagent, auto-hidden when none.
7
+
8
+ Threading: tool calls run in an executor thread (see ui/app.py) so
9
+ SubagentManager mutates from worker threads. Every mutation grabs a lock and
10
+ listeners are called with a snapshot copy so the UI can iterate without
11
+ holding it.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ import time
17
+ import uuid
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass, field, replace
20
+ from typing import Literal
21
+
22
+ SubagentStatus = Literal["running", "done", "error"]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SubagentSnapshot:
27
+ id: str
28
+ name: str
29
+ status: SubagentStatus
30
+ started_at: float
31
+ last_action: str = ""
32
+ finished_at: float | None = None
33
+ error: str = ""
34
+
35
+ @property
36
+ def elapsed_seconds(self) -> float:
37
+ end = self.finished_at if self.finished_at is not None else time.monotonic()
38
+ return end - self.started_at
39
+
40
+
41
+ @dataclass
42
+ class _Row:
43
+ id: str
44
+ name: str
45
+ status: SubagentStatus
46
+ started_at: float
47
+ last_action: str = ""
48
+ finished_at: float | None = None
49
+ error: str = ""
50
+ # Small display grace period after finish so completed agents flash
51
+ # briefly before disappearing (see prune_finished).
52
+ linger_until: float = field(default=0.0)
53
+
54
+
55
+ Listener = Callable[[list[SubagentSnapshot]], None]
56
+
57
+ LINGER_SECONDS = 2.0
58
+
59
+
60
+ class SubagentManager:
61
+ def __init__(self) -> None:
62
+ self._rows: dict[str, _Row] = {}
63
+ self._lock = threading.Lock()
64
+ self._listeners: list[Listener] = []
65
+
66
+ # --- subscribe --------------------------------------------------------
67
+
68
+ def subscribe(self, listener: Listener) -> Callable[[], None]:
69
+ with self._lock:
70
+ self._listeners.append(listener)
71
+
72
+ def _unsub() -> None:
73
+ with self._lock:
74
+ if listener in self._listeners:
75
+ self._listeners.remove(listener)
76
+ return _unsub
77
+
78
+ def _notify(self) -> None:
79
+ # Snapshot outside the lock so listener callbacks don't reenter it.
80
+ snap = self.snapshot()
81
+ for listener in list(self._listeners):
82
+ try:
83
+ listener(snap)
84
+ except Exception: # noqa: BLE001 - a broken listener must not kill agents
85
+ pass
86
+
87
+ # --- lifecycle --------------------------------------------------------
88
+
89
+ def start(self, name: str) -> str:
90
+ agent_id = uuid.uuid4().hex[:8]
91
+ with self._lock:
92
+ self._rows[agent_id] = _Row(
93
+ id=agent_id, name=name, status="running", started_at=time.monotonic()
94
+ )
95
+ self._notify()
96
+ return agent_id
97
+
98
+ def update(self, agent_id: str, *, last_action: str) -> None:
99
+ with self._lock:
100
+ row = self._rows.get(agent_id)
101
+ if row is None or row.status != "running":
102
+ return
103
+ row.last_action = last_action
104
+ self._notify()
105
+
106
+ def finish(self, agent_id: str, *, status: SubagentStatus = "done", error: str = "") -> None:
107
+ with self._lock:
108
+ row = self._rows.get(agent_id)
109
+ if row is None:
110
+ return
111
+ row.status = status
112
+ row.finished_at = time.monotonic()
113
+ row.error = error
114
+ row.linger_until = row.finished_at + LINGER_SECONDS
115
+ self._notify()
116
+
117
+ # --- read -------------------------------------------------------------
118
+
119
+ def snapshot(self) -> list[SubagentSnapshot]:
120
+ with self._lock:
121
+ return [
122
+ SubagentSnapshot(
123
+ id=r.id, name=r.name, status=r.status, started_at=r.started_at,
124
+ last_action=r.last_action, finished_at=r.finished_at, error=r.error,
125
+ )
126
+ for r in self._rows.values()
127
+ ]
128
+
129
+ def visible_rows(self) -> list[SubagentSnapshot]:
130
+ """Rows to actually render — running + recently-finished (linger)."""
131
+ now = time.monotonic()
132
+ with self._lock:
133
+ keep: list[_Row] = []
134
+ for row in self._rows.values():
135
+ if row.status == "running" or row.linger_until > now:
136
+ keep.append(row)
137
+ return [
138
+ SubagentSnapshot(
139
+ id=r.id, name=r.name, status=r.status, started_at=r.started_at,
140
+ last_action=r.last_action, finished_at=r.finished_at, error=r.error,
141
+ )
142
+ for r in keep
143
+ ]
144
+
145
+ def prune_finished(self) -> bool:
146
+ """Drop finished rows past their linger window. Returns True if any
147
+ row was removed (so the TUI knows to invalidate and re-render). Called
148
+ from the spinner tick."""
149
+ now = time.monotonic()
150
+ removed = False
151
+ with self._lock:
152
+ for aid, row in list(self._rows.items()):
153
+ if row.status != "running" and row.linger_until <= now:
154
+ del self._rows[aid]
155
+ removed = True
156
+ if removed:
157
+ self._notify()
158
+ return removed
159
+
160
+ def any_active(self) -> bool:
161
+ with self._lock:
162
+ return any(r.status == "running" for r in self._rows.values())
163
+
164
+ # Kept for tests / debugging.
165
+ def _clear(self) -> None:
166
+ with self._lock:
167
+ self._rows.clear()
168
+
169
+
170
+ # For type hints elsewhere without a heavy import.
171
+ __all__ = ["SubagentManager", "SubagentSnapshot", "LINGER_SECONDS"]
172
+
173
+ # Discourage silent replace() calls on the frozen dataclass elsewhere.
174
+ _ = replace # noqa: F841
@@ -0,0 +1,4 @@
1
+ from reidx.session.models import Session, SessionStatus
2
+ from reidx.session.store import SessionStore
3
+
4
+ __all__ = ["Session", "SessionStatus", "SessionStore"]
@@ -0,0 +1,43 @@
1
+ """Session model.
2
+
3
+ A Session is a first-class runtime object: identity, workspace, provider/model state,
4
+ permission mode, status, and timestamps. Transcript and task state live alongside
5
+ under the session storage directory (see session.store).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from reidx.policy.models import PermissionMode
16
+
17
+
18
+ def _new_id() -> str:
19
+ import uuid
20
+
21
+ return uuid.uuid4().hex[:12]
22
+
23
+
24
+ class SessionStatus(StrEnum):
25
+ ACTIVE = "active"
26
+ PAUSED = "paused"
27
+ ARCHIVED = "archived"
28
+
29
+
30
+ class Session(BaseModel):
31
+ id: str = Field(default_factory=_new_id)
32
+ title: str = ""
33
+ workspace: Path
34
+ provider: str = "stub"
35
+ model: str = "stub-v0"
36
+ reasoning_effort: str = "medium"
37
+ permission_mode: PermissionMode = PermissionMode.BALANCED
38
+ status: SessionStatus = SessionStatus.ACTIVE
39
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
40
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
41
+
42
+ def touch(self) -> None:
43
+ self.updated_at = datetime.now(UTC)
@@ -0,0 +1,101 @@
1
+ """Session store: structured per-session persistence.
2
+
3
+ Layout under storage_root:
4
+ sessions/
5
+ <session-id>/
6
+ meta.json # Session record
7
+ transcript.jsonl # one Message per line (restorable into state.messages)
8
+ tasks.json # task state for the session
9
+ events.jsonl # runtime action log (turn summaries, lifecycle events)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from reidx.diagnostics.logger import EventLog, get_logger
16
+ from reidx.provider.base import Message
17
+ from reidx.session.models import Session, SessionStatus
18
+
19
+ log = get_logger("reidx.session")
20
+
21
+ _MAX_RESUME_MESSAGES = 100
22
+
23
+
24
+ class SessionStore:
25
+ def __init__(self, storage_root: Path) -> None:
26
+ self.root = storage_root / "sessions"
27
+ self.root.mkdir(parents=True, exist_ok=True)
28
+
29
+ def _dir(self, session_id: str) -> Path:
30
+ return self.root / session_id
31
+
32
+ def create(self, session: Session) -> Session:
33
+ d = self._dir(session.id)
34
+ d.mkdir(parents=True, exist_ok=True)
35
+ self._write_meta(session)
36
+ log.debug("created session %s in %s", session.id, session.workspace)
37
+ return session
38
+
39
+ def _write_meta(self, session: Session) -> None:
40
+ session.touch()
41
+ (self._dir(session.id) / "meta.json").write_text(
42
+ session.model_dump_json(indent=2), encoding="utf-8"
43
+ )
44
+
45
+ def get(self, session_id: str) -> Session | None:
46
+ path = self._dir(session_id) / "meta.json"
47
+ if not path.exists():
48
+ return None
49
+ return Session.model_validate_json(path.read_text(encoding="utf-8"))
50
+
51
+ def update(self, session: Session) -> None:
52
+ if not self._dir(session.id).exists():
53
+ raise FileNotFoundError(f"session {session.id} not found")
54
+ self._write_meta(session)
55
+
56
+ def list(self) -> list[Session]:
57
+ sessions: list[Session] = []
58
+ for child in sorted(self.root.iterdir()):
59
+ meta = child / "meta.json"
60
+ if meta.exists():
61
+ sessions.append(Session.model_validate_json(meta.read_text(encoding="utf-8")))
62
+ return sessions
63
+
64
+ def set_status(self, session_id: str, status: SessionStatus) -> Session | None:
65
+ s = self.get(session_id)
66
+ if s is None:
67
+ return None
68
+ s.status = status
69
+ self.update(s)
70
+ return s
71
+
72
+ def session_dir(self, session_id: str) -> Path:
73
+ return self._dir(session_id)
74
+
75
+ def event_log(self, session_id: str) -> EventLog:
76
+ return EventLog(self._dir(session_id) / "events.jsonl")
77
+
78
+ def transcript_log(self, session_id: str) -> EventLog:
79
+ return EventLog(self._dir(session_id) / "transcript.jsonl")
80
+
81
+ def append_message(self, session_id: str, message: Message) -> None:
82
+ """Append one Message to transcript.jsonl for restorable resume."""
83
+ path = self._dir(session_id) / "transcript.jsonl"
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ line = message.model_dump_json()
86
+ with path.open("a", encoding="utf-8") as fh:
87
+ fh.write(line + "\n")
88
+
89
+ def read_messages(self, session_id: str, limit: int = _MAX_RESUME_MESSAGES) -> list[Message]:
90
+ """Read transcript.jsonl back into Message objects (most recent `limit`)."""
91
+ path = self._dir(session_id) / "transcript.jsonl"
92
+ if not path.exists():
93
+ return []
94
+ lines = path.read_text(encoding="utf-8").splitlines()
95
+ messages: list[Message] = []
96
+ for line in lines[-limit:]:
97
+ try:
98
+ messages.append(Message.model_validate_json(line))
99
+ except Exception: # noqa: BLE001 - skip corrupt lines
100
+ continue
101
+ return messages
@@ -0,0 +1,4 @@
1
+ from reidx.tasks.models import Task, TaskStatus
2
+ from reidx.tasks.store import TaskStore
3
+
4
+ __all__ = ["Task", "TaskStatus", "TaskStore"]
@@ -0,0 +1,44 @@
1
+ """Task model.
2
+
3
+ Structured task state — not prose steps. Tasks belong to a session, track status
4
+ through a real state machine, and support dependencies.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from datetime import UTC, datetime
9
+ from enum import StrEnum
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ def _new_id() -> str:
16
+ import uuid
17
+
18
+ return uuid.uuid4().hex[:10]
19
+
20
+
21
+ class TaskStatus(StrEnum):
22
+ PENDING = "pending"
23
+ ACTIVE = "active"
24
+ COMPLETED = "completed"
25
+ FAILED = "failed"
26
+ BLOCKED = "blocked"
27
+ SKIPPED = "skipped"
28
+
29
+
30
+ class Task(BaseModel):
31
+ id: str = Field(default_factory=_new_id)
32
+ session_id: str
33
+ title: str
34
+ status: TaskStatus = TaskStatus.PENDING
35
+ summary: str = ""
36
+ error: str = ""
37
+ depends_on: list[str] = Field(default_factory=list)
38
+ notes: list[str] = Field(default_factory=list)
39
+ meta: dict[str, Any] = Field(default_factory=dict)
40
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
41
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
42
+
43
+ def touch(self) -> None:
44
+ self.updated_at = datetime.now(UTC)
@@ -0,0 +1,86 @@
1
+ """Task store: per-session task state persisted to tasks.json."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from reidx.diagnostics.logger import get_logger
8
+ from reidx.tasks.models import Task, TaskStatus
9
+
10
+ log = get_logger("reidx.tasks")
11
+
12
+
13
+ class TaskStore:
14
+ def __init__(self, storage_root: Path, session_id: str) -> None:
15
+ self.path = storage_root / "sessions" / session_id / "tasks.json"
16
+ self.path.parent.mkdir(parents=True, exist_ok=True)
17
+ self.session_id = session_id
18
+
19
+ def _read(self) -> dict[str, Task]:
20
+ if not self.path.exists():
21
+ return {}
22
+ import json
23
+
24
+ text = self.path.read_text(encoding="utf-8").strip()
25
+ if not text:
26
+ return {}
27
+
28
+ data: dict[str, Task] = {}
29
+ for raw in json.loads(text).get("tasks", []):
30
+ t = Task.model_validate(raw)
31
+ data[t.id] = t
32
+ return data
33
+
34
+ def _write(self, tasks: dict[str, Task]) -> None:
35
+ import json
36
+
37
+ payload = {"tasks": [t.model_dump(mode="json") for t in tasks.values()]}
38
+ self.path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
39
+
40
+ def create(
41
+ self,
42
+ title: str,
43
+ depends_on: list[str] | None = None,
44
+ meta: dict[str, Any] | None = None,
45
+ ) -> Task:
46
+ tasks = self._read()
47
+ t = Task(
48
+ session_id=self.session_id,
49
+ title=title,
50
+ depends_on=depends_on or [],
51
+ meta=meta or {},
52
+ )
53
+ tasks[t.id] = t
54
+ self._write(tasks)
55
+ log.debug("created task %s: %s", t.id, title)
56
+ return t
57
+
58
+ def get(self, task_id: str) -> Task | None:
59
+ return self._read().get(task_id)
60
+
61
+ def list(self) -> list[Task]:
62
+ return list(self._read().values())
63
+
64
+ def update_status(self, task_id: str, status: TaskStatus, summary: str = "", error: str = "") -> Task | None:
65
+ tasks = self._read()
66
+ t = tasks.get(task_id)
67
+ if t is None:
68
+ return None
69
+ t.status = status
70
+ if summary:
71
+ t.summary = summary
72
+ if error:
73
+ t.error = error
74
+ t.touch()
75
+ self._write(tasks)
76
+ return t
77
+
78
+ def add_note(self, task_id: str, note: str) -> Task | None:
79
+ tasks = self._read()
80
+ t = tasks.get(task_id)
81
+ if t is None:
82
+ return None
83
+ t.notes.append(note)
84
+ t.touch()
85
+ self._write(tasks)
86
+ return t
@@ -0,0 +1,44 @@
1
+ from reidx.tools.base import BaseTool, ToolContext, ToolDefinition, ToolResult
2
+ from reidx.tools.file_tools import (
3
+ FindFilesTool,
4
+ GrepFilesTool,
5
+ ListDirTool,
6
+ PatchFileTool,
7
+ ReadFileTool,
8
+ WriteFileTool,
9
+ register_file_tools,
10
+ )
11
+ from reidx.tools.registry import ToolRegistry
12
+ from reidx.tools.shell_tool import RunCommandTool, register_shell_tool
13
+ from reidx.tools.spawn_agent import SpawnAgentTool, register_spawn_agent
14
+ from reidx.tools.web_tools import WebSearchTool, register_web_tools
15
+
16
+ __all__ = [
17
+ "BaseTool",
18
+ "FindFilesTool",
19
+ "GrepFilesTool",
20
+ "ListDirTool",
21
+ "PatchFileTool",
22
+ "ReadFileTool",
23
+ "RunCommandTool",
24
+ "SpawnAgentTool",
25
+ "ToolContext",
26
+ "ToolDefinition",
27
+ "ToolRegistry",
28
+ "ToolResult",
29
+ "WebSearchTool",
30
+ "WriteFileTool",
31
+ "register_file_tools",
32
+ "register_shell_tool",
33
+ "register_spawn_agent",
34
+ "register_web_tools",
35
+ ]
36
+
37
+
38
+ def default_registry() -> ToolRegistry:
39
+ reg = ToolRegistry()
40
+ register_file_tools(reg)
41
+ register_shell_tool(reg)
42
+ register_web_tools(reg)
43
+ register_spawn_agent(reg)
44
+ return reg
@@ -0,0 +1,84 @@
1
+ """Tool system contracts.
2
+
3
+ A tool has a definition (name + description + JSON-schema parameters + risk level)
4
+ and an execute() entry point. Tools receive a ToolContext carrying the workspace,
5
+ writable roots, the policy engine, and an approver callable used to resolve PROMPT
6
+ decisions into ALLOW/DENY. Tool results are structured, never raised as exceptions
7
+ when avoidable — failures become ToolResult(ok=False, error=...).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from reidx.policy.engine import PolicyEngine
20
+ from reidx.policy.models import PermissionDecision, RiskLevel
21
+
22
+
23
+ class ToolDefinition(BaseModel):
24
+ name: str
25
+ description: str
26
+ parameters: dict[str, Any] = Field(default_factory=dict)
27
+ risk: RiskLevel = RiskLevel.MEDIUM
28
+
29
+
30
+ class ToolResult(BaseModel):
31
+ ok: bool = True
32
+ output: str = ""
33
+ error: str = ""
34
+ data: dict[str, Any] = Field(default_factory=dict)
35
+
36
+ @classmethod
37
+ def fail(cls, error: str) -> ToolResult:
38
+ return cls(ok=False, error=error)
39
+
40
+ @classmethod
41
+ def ok_(cls, output: str = "", **data: Any) -> ToolResult:
42
+ return cls(ok=True, output=output, data=data)
43
+
44
+
45
+ # Approver signature: (prompt_text) -> bool. Supplied by the UI layer.
46
+ Approver = Callable[[str], bool]
47
+
48
+
49
+ @dataclass
50
+ class ToolContext:
51
+ workspace_root: Path
52
+ policy: PolicyEngine
53
+ writable_roots: list[Path] = field(default_factory=list)
54
+ approver: Approver | None = None
55
+ extra: dict[str, Any] = field(default_factory=dict)
56
+
57
+ def resolve_decision(self, prompt_text: str) -> PermissionDecision:
58
+ """Resolve a PROMPT decision via the approver; defaults to DENY if none set."""
59
+ if self.approver is None:
60
+ return PermissionDecision.DENY
61
+ return PermissionDecision.ALLOW if self.approver(prompt_text) else PermissionDecision.DENY
62
+
63
+
64
+ class BaseTool(ABC):
65
+ @property
66
+ @abstractmethod
67
+ def definition(self) -> ToolDefinition:
68
+ ...
69
+
70
+ @abstractmethod
71
+ def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
72
+ ...
73
+
74
+ def schema(self) -> dict[str, Any]:
75
+ """OpenAI-style function schema for provider tool passing."""
76
+ d = self.definition
77
+ return {
78
+ "type": "function",
79
+ "function": {
80
+ "name": d.name,
81
+ "description": d.description,
82
+ "parameters": d.parameters or {"type": "object", "properties": {}},
83
+ },
84
+ }