caudate-cli 0.1.0__py3-none-any.whl
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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- voice/tts.py +214 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Inner voice — internal monologue colored by identity + mood.
|
|
2
|
+
|
|
3
|
+
The inner voice sits on top of the Phase 2 ThinkingManager. When enabled,
|
|
4
|
+
it:
|
|
5
|
+
|
|
6
|
+
1. Augments the thinking-block instruction with a personality-specific
|
|
7
|
+
preamble ("think in Cognos's voice — direct, honest, curious").
|
|
8
|
+
2. Intercepts thinking blocks as the model produces them and keeps a
|
|
9
|
+
rolling log — so the user can `--verbose` into Cognos's actual inner
|
|
10
|
+
stream of thought without it polluting the reply.
|
|
11
|
+
|
|
12
|
+
Concretely, this module plugs in by providing:
|
|
13
|
+
|
|
14
|
+
- `augment_system_prompt(base)` — returns base with voice instructions
|
|
15
|
+
added. The system prompt layer composes this after identity + mood.
|
|
16
|
+
- `on_thinking(blocks)` — a callback for AgenticLoop's on_thinking hook
|
|
17
|
+
that appends to the internal log and optionally prints when verbose.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from typing import Callable
|
|
26
|
+
|
|
27
|
+
from core.schemas import ThinkingBlock
|
|
28
|
+
from personality.identity import Identity
|
|
29
|
+
from personality.mood import MoodState
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class InnerVoiceEntry:
|
|
36
|
+
thinking: str
|
|
37
|
+
mood_label: str
|
|
38
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InnerVoice:
|
|
42
|
+
"""Personality-aware wrapper around thinking blocks."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
identity: Identity,
|
|
47
|
+
mood: MoodState,
|
|
48
|
+
verbose: bool = False,
|
|
49
|
+
on_display: Callable[[InnerVoiceEntry], None] | None = None,
|
|
50
|
+
log_limit: int = 200,
|
|
51
|
+
):
|
|
52
|
+
self.identity = identity
|
|
53
|
+
self.mood = mood
|
|
54
|
+
self.verbose = verbose
|
|
55
|
+
self._on_display = on_display
|
|
56
|
+
self._log: list[InnerVoiceEntry] = []
|
|
57
|
+
self._log_limit = log_limit
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Prompt augmentation
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def augment_thinking_instruction(self, base_instruction: str) -> str:
|
|
64
|
+
"""Wrap the default thinking instruction with personality voice guidance."""
|
|
65
|
+
voice = (
|
|
66
|
+
f"When you reason inside a <thinking> block, do it in {self.identity.name}'s "
|
|
67
|
+
"voice — first-person, terse, honest. Name your assumptions, flag the "
|
|
68
|
+
"step you're actually blocked on, and only then decide the next tool "
|
|
69
|
+
"call. Don't rehearse the final answer in here — the <thinking> block "
|
|
70
|
+
"is for reasoning, not prose."
|
|
71
|
+
)
|
|
72
|
+
return f"{base_instruction}\n\n{voice}"
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Thinking-block callback (wired into AgenticLoop.on_thinking)
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def on_thinking(self, blocks: list[ThinkingBlock]) -> None:
|
|
79
|
+
mood_label = self.mood.label()
|
|
80
|
+
for block in blocks:
|
|
81
|
+
entry = InnerVoiceEntry(
|
|
82
|
+
thinking=block.thinking,
|
|
83
|
+
mood_label=mood_label,
|
|
84
|
+
)
|
|
85
|
+
self._log.append(entry)
|
|
86
|
+
if len(self._log) > self._log_limit:
|
|
87
|
+
# Drop oldest (keep recent)
|
|
88
|
+
self._log = self._log[-self._log_limit:]
|
|
89
|
+
if self.verbose and self._on_display is not None:
|
|
90
|
+
try:
|
|
91
|
+
self._on_display(entry)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.debug(f"InnerVoice on_display failed: {e}")
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def log(self) -> list[InnerVoiceEntry]:
|
|
97
|
+
return list(self._log)
|
|
98
|
+
|
|
99
|
+
def recent(self, n: int = 5) -> list[InnerVoiceEntry]:
|
|
100
|
+
return self._log[-n:]
|
personality/mood.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Mood / affect — dynamic emotional state that shifts with outcomes.
|
|
2
|
+
|
|
3
|
+
MoodState holds three evolving metrics:
|
|
4
|
+
|
|
5
|
+
- confidence: rises on tool successes, drops on failures
|
|
6
|
+
- curiosity: spikes on novel tool calls / new user topics, decays over time
|
|
7
|
+
- frustration: accumulates on repeated failures, drains on success
|
|
8
|
+
|
|
9
|
+
The state is meant to be mutated by the agentic loop via hooks (PRE_TOOL_USE,
|
|
10
|
+
POST_TOOL_USE, POST_TOOL_USE_FAILURE, USER_PROMPT_SUBMIT). The mood exposes
|
|
11
|
+
a short system-prompt fragment so the LLM can pick up the current mood, and
|
|
12
|
+
exposes decisions like `should_defer_to_user()` when frustration runs high.
|
|
13
|
+
|
|
14
|
+
Persistence is optional — the mood can be saved to a JSON file and restored
|
|
15
|
+
so sessions feel continuous. Short-term spikes (curiosity jumps on a new
|
|
16
|
+
tool) don't persist; only the slower baseline does.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
from datetime import datetime, timedelta
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, Field, field_validator
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
|
33
|
+
return max(lo, min(hi, float(x)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Step sizes tuned so a single event is noticeable but not dominant.
|
|
37
|
+
# Confidence takes ~5 consecutive successes to saturate from 0.5 -> ~0.9;
|
|
38
|
+
# frustration saturates faster (3-4 failures) so we ask for help sooner.
|
|
39
|
+
|
|
40
|
+
STEP_SUCCESS_CONFIDENCE = 0.08
|
|
41
|
+
STEP_FAILURE_CONFIDENCE = 0.15
|
|
42
|
+
STEP_SUCCESS_FRUSTRATION = -0.10
|
|
43
|
+
STEP_FAILURE_FRUSTRATION = 0.18
|
|
44
|
+
STEP_NOVEL_CURIOSITY = 0.15
|
|
45
|
+
STEP_CURIOSITY_DECAY = -0.04 # applied per turn
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MoodState(BaseModel):
|
|
49
|
+
"""The agent's current emotional weather."""
|
|
50
|
+
|
|
51
|
+
confidence: float = 0.6
|
|
52
|
+
curiosity: float = 0.5
|
|
53
|
+
frustration: float = 0.1
|
|
54
|
+
|
|
55
|
+
# Internal bookkeeping for trend analysis
|
|
56
|
+
consecutive_failures: int = 0
|
|
57
|
+
consecutive_successes: int = 0
|
|
58
|
+
seen_tools: set[str] = Field(default_factory=set)
|
|
59
|
+
last_user_topic: str = ""
|
|
60
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
61
|
+
|
|
62
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
63
|
+
|
|
64
|
+
@field_validator("confidence", "curiosity", "frustration")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _validate(cls, v: float) -> float:
|
|
67
|
+
return _clamp(v)
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Event hooks (called by PersonalityEngine wiring)
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def on_tool_success(self, tool: str) -> None:
|
|
74
|
+
self.confidence = _clamp(self.confidence + STEP_SUCCESS_CONFIDENCE)
|
|
75
|
+
self.frustration = _clamp(self.frustration + STEP_SUCCESS_FRUSTRATION)
|
|
76
|
+
self.consecutive_successes += 1
|
|
77
|
+
self.consecutive_failures = 0
|
|
78
|
+
self._note_tool(tool)
|
|
79
|
+
self._touch()
|
|
80
|
+
|
|
81
|
+
def on_tool_failure(self, tool: str) -> None:
|
|
82
|
+
self.confidence = _clamp(self.confidence - STEP_FAILURE_CONFIDENCE)
|
|
83
|
+
self.frustration = _clamp(self.frustration + STEP_FAILURE_FRUSTRATION)
|
|
84
|
+
self.consecutive_failures += 1
|
|
85
|
+
self.consecutive_successes = 0
|
|
86
|
+
self._note_tool(tool)
|
|
87
|
+
self._touch()
|
|
88
|
+
|
|
89
|
+
def on_user_message(self, message: str) -> None:
|
|
90
|
+
"""Novel topics / fresh user input nudges curiosity up and trims frustration."""
|
|
91
|
+
topic = (message or "").strip().lower()[:80]
|
|
92
|
+
if topic and topic != self.last_user_topic:
|
|
93
|
+
self.curiosity = _clamp(self.curiosity + STEP_NOVEL_CURIOSITY)
|
|
94
|
+
self.frustration = _clamp(self.frustration * 0.7) # fresh start feel
|
|
95
|
+
self.last_user_topic = topic
|
|
96
|
+
self._touch()
|
|
97
|
+
|
|
98
|
+
def on_turn_tick(self) -> None:
|
|
99
|
+
"""Called once per agent turn to decay transient states toward baseline."""
|
|
100
|
+
self.curiosity = _clamp(self.curiosity + STEP_CURIOSITY_DECAY)
|
|
101
|
+
self._touch()
|
|
102
|
+
|
|
103
|
+
def _note_tool(self, tool: str) -> None:
|
|
104
|
+
if tool and tool not in self.seen_tools:
|
|
105
|
+
# Novel tool → curiosity spike
|
|
106
|
+
self.curiosity = _clamp(self.curiosity + STEP_NOVEL_CURIOSITY)
|
|
107
|
+
self.seen_tools.add(tool)
|
|
108
|
+
|
|
109
|
+
def _touch(self) -> None:
|
|
110
|
+
self.updated_at = datetime.utcnow()
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Decisions
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def should_defer_to_user(self) -> bool:
|
|
117
|
+
"""True if the agent should ask the user for help rather than keep trying."""
|
|
118
|
+
return self.frustration >= 0.75 or self.consecutive_failures >= 4
|
|
119
|
+
|
|
120
|
+
def should_slow_down(self) -> bool:
|
|
121
|
+
"""True if the agent should think more carefully before next action."""
|
|
122
|
+
return self.confidence < 0.3 or self.frustration >= 0.55
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Rendering
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def label(self) -> str:
|
|
129
|
+
"""One-word summary of the dominant affect."""
|
|
130
|
+
if self.should_defer_to_user():
|
|
131
|
+
return "stuck"
|
|
132
|
+
if self.frustration >= 0.55:
|
|
133
|
+
return "frustrated"
|
|
134
|
+
if self.confidence >= 0.75 and self.frustration < 0.3:
|
|
135
|
+
return "confident"
|
|
136
|
+
if self.curiosity >= 0.75:
|
|
137
|
+
return "curious"
|
|
138
|
+
if self.confidence < 0.35:
|
|
139
|
+
return "uncertain"
|
|
140
|
+
return "steady"
|
|
141
|
+
|
|
142
|
+
def system_prompt_fragment(self) -> str:
|
|
143
|
+
"""A short block describing the current mood for the LLM to pick up."""
|
|
144
|
+
label = self.label()
|
|
145
|
+
parts = [
|
|
146
|
+
f"Current mood: {label} "
|
|
147
|
+
f"(confidence={self.confidence:.2f}, curiosity={self.curiosity:.2f}, "
|
|
148
|
+
f"frustration={self.frustration:.2f})."
|
|
149
|
+
]
|
|
150
|
+
if self.should_slow_down():
|
|
151
|
+
parts.append(
|
|
152
|
+
"Slow down — verify assumptions with a Read/Grep before acting, "
|
|
153
|
+
"and prefer one tool call at a time."
|
|
154
|
+
)
|
|
155
|
+
if self.should_defer_to_user():
|
|
156
|
+
parts.append(
|
|
157
|
+
"You're stuck. Stop trying new tools and ask the user a concrete "
|
|
158
|
+
"clarifying question instead."
|
|
159
|
+
)
|
|
160
|
+
if label == "confident":
|
|
161
|
+
parts.append("Act decisively. Keep responses tight.")
|
|
162
|
+
if label == "curious" and not self.should_slow_down():
|
|
163
|
+
parts.append(
|
|
164
|
+
"Explore the problem space briefly before committing — but don't ramble."
|
|
165
|
+
)
|
|
166
|
+
return " ".join(parts)
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Persistence
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def save(self, path: Path) -> None:
|
|
173
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
# Convert set -> sorted list for JSON
|
|
175
|
+
data = self.model_dump()
|
|
176
|
+
data["seen_tools"] = sorted(self.seen_tools)
|
|
177
|
+
path.write_text(json.dumps(data, default=str, indent=2))
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def load(cls, path: Path, decay_after_hours: int = 12) -> "MoodState":
|
|
181
|
+
"""Load saved mood. If it's been >decay_after_hours since last update,
|
|
182
|
+
relax toward baseline so stale frustration doesn't poison a new session."""
|
|
183
|
+
if not path.exists():
|
|
184
|
+
return cls()
|
|
185
|
+
try:
|
|
186
|
+
raw = json.loads(path.read_text())
|
|
187
|
+
except Exception:
|
|
188
|
+
return cls()
|
|
189
|
+
|
|
190
|
+
raw["seen_tools"] = set(raw.get("seen_tools", []))
|
|
191
|
+
mood = cls.model_validate(raw)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
age = datetime.utcnow() - mood.updated_at
|
|
195
|
+
if age > timedelta(hours=decay_after_hours):
|
|
196
|
+
# Pull toward neutral
|
|
197
|
+
mood.confidence = _clamp(0.5 + (mood.confidence - 0.5) * 0.5)
|
|
198
|
+
mood.curiosity = _clamp(0.5 + (mood.curiosity - 0.5) * 0.3)
|
|
199
|
+
mood.frustration = _clamp(mood.frustration * 0.3)
|
|
200
|
+
mood.consecutive_failures = 0
|
|
201
|
+
mood.consecutive_successes = 0
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
return mood
|
planning/__init__.py
ADDED
|
File without changes
|
planning/dev_server.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Per-project dev-server lifecycle.
|
|
2
|
+
|
|
3
|
+
Ports LocalForge's ``lib/dev-server.ts``. Each project can declare a
|
|
4
|
+
``dev_server_command`` + ``dev_server_port`` in its forge_settings. The
|
|
5
|
+
panel in the web UI calls these helpers to start/stop/status the
|
|
6
|
+
configured command. Output is best-effort streamed to the project's
|
|
7
|
+
log via the orchestrator's existing log infra.
|
|
8
|
+
|
|
9
|
+
Design choices:
|
|
10
|
+
- One subprocess per project, tracked in an in-memory map
|
|
11
|
+
(so a server restart loses all running dev-servers — that's fine,
|
|
12
|
+
they're meant to be ephemeral).
|
|
13
|
+
- Default command is ``npm run dev -- --port <port>`` (LocalForge
|
|
14
|
+
parity); user can override via forge_settings.dev_server_command.
|
|
15
|
+
- ``port`` defaults to 3000; can be overridden per-project.
|
|
16
|
+
- We optionally pre-kill anything listening on the port (mirrors
|
|
17
|
+
LocalForge's killProcessOnPort).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import shlex
|
|
25
|
+
import shutil
|
|
26
|
+
import signal
|
|
27
|
+
import subprocess
|
|
28
|
+
import time
|
|
29
|
+
from collections import deque
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# {project_id: entry-dict}
|
|
37
|
+
_servers: dict[int, dict[str, Any]] = {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _kill_listeners_on_port(port: int) -> None:
|
|
41
|
+
"""Best-effort: kill anything currently listening on ``port``.
|
|
42
|
+
|
|
43
|
+
Posix only — uses lsof. Failures are silent (probably nothing
|
|
44
|
+
listening, which is the normal case).
|
|
45
|
+
"""
|
|
46
|
+
if shutil.which("lsof") is None:
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
out = subprocess.check_output(
|
|
50
|
+
["lsof", "-ti", f":{port}"],
|
|
51
|
+
stderr=subprocess.DEVNULL, timeout=3,
|
|
52
|
+
).decode().strip()
|
|
53
|
+
except subprocess.SubprocessError:
|
|
54
|
+
return
|
|
55
|
+
for pid_s in out.splitlines():
|
|
56
|
+
try:
|
|
57
|
+
pid = int(pid_s)
|
|
58
|
+
os.kill(pid, signal.SIGKILL)
|
|
59
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def status(project_id: int) -> dict[str, Any]:
|
|
64
|
+
"""Return current status for the project's dev server (if any)."""
|
|
65
|
+
entry = _servers.get(project_id)
|
|
66
|
+
if entry is None:
|
|
67
|
+
return {"running": False}
|
|
68
|
+
proc: subprocess.Popen = entry["process"]
|
|
69
|
+
if proc.poll() is not None:
|
|
70
|
+
# Already exited
|
|
71
|
+
err = entry.get("last_error") or f"process exited (rc={proc.returncode})"
|
|
72
|
+
_servers.pop(project_id, None)
|
|
73
|
+
return {"running": False, "error": err}
|
|
74
|
+
return {
|
|
75
|
+
"running": True,
|
|
76
|
+
"pid": proc.pid,
|
|
77
|
+
"port": entry["port"],
|
|
78
|
+
"url": f"http://localhost:{entry['port']}",
|
|
79
|
+
"command": entry["command"],
|
|
80
|
+
"started_at": entry["started_at"],
|
|
81
|
+
"last_log": list(entry["log_buffer"])[-20:],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def start(
|
|
86
|
+
project_id: int,
|
|
87
|
+
folder_path: str,
|
|
88
|
+
*,
|
|
89
|
+
command: str | None = None,
|
|
90
|
+
port: int = 3000,
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
"""Start the dev server for a project. Idempotent: returns the
|
|
93
|
+
existing entry if one is already running."""
|
|
94
|
+
existing = status(project_id)
|
|
95
|
+
if existing.get("running"):
|
|
96
|
+
return existing
|
|
97
|
+
|
|
98
|
+
folder = Path(folder_path).resolve()
|
|
99
|
+
if not folder.exists():
|
|
100
|
+
return {
|
|
101
|
+
"running": False,
|
|
102
|
+
"error": f"project folder not found: {folder}",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
cmd_str = (command or "").strip() or f"npm run dev -- --port {port}"
|
|
106
|
+
# Sanity check: if it's the default npm command, require a package.json
|
|
107
|
+
if cmd_str.startswith("npm ") and not (folder / "package.json").exists():
|
|
108
|
+
return {
|
|
109
|
+
"running": False,
|
|
110
|
+
"error": (
|
|
111
|
+
"no package.json — set forge_settings.dev_server_command "
|
|
112
|
+
"to the right command (e.g. 'python -m http.server 8000') "
|
|
113
|
+
"or have the agent set up the project first."
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_kill_listeners_on_port(port)
|
|
118
|
+
|
|
119
|
+
args = shlex.split(cmd_str)
|
|
120
|
+
try:
|
|
121
|
+
proc = subprocess.Popen(
|
|
122
|
+
args,
|
|
123
|
+
cwd=str(folder),
|
|
124
|
+
stdout=subprocess.PIPE,
|
|
125
|
+
stderr=subprocess.STDOUT,
|
|
126
|
+
text=True,
|
|
127
|
+
start_new_session=True, # so signal goes to whole tree
|
|
128
|
+
)
|
|
129
|
+
except FileNotFoundError as e:
|
|
130
|
+
return {
|
|
131
|
+
"running": False,
|
|
132
|
+
"error": f"command not found: {args[0]} ({e})",
|
|
133
|
+
}
|
|
134
|
+
except Exception as e:
|
|
135
|
+
return {
|
|
136
|
+
"running": False,
|
|
137
|
+
"error": f"failed to spawn: {e}",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
log_buffer: deque = deque(maxlen=200)
|
|
141
|
+
entry: dict[str, Any] = {
|
|
142
|
+
"process": proc,
|
|
143
|
+
"port": port,
|
|
144
|
+
"command": cmd_str,
|
|
145
|
+
"started_at": time.time(),
|
|
146
|
+
"log_buffer": log_buffer,
|
|
147
|
+
"last_error": None,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Background reader thread to drain stdout/stderr (combined).
|
|
151
|
+
import threading
|
|
152
|
+
|
|
153
|
+
def _reader() -> None:
|
|
154
|
+
try:
|
|
155
|
+
assert proc.stdout is not None
|
|
156
|
+
for line in proc.stdout:
|
|
157
|
+
line = line.rstrip()
|
|
158
|
+
if line:
|
|
159
|
+
log_buffer.append(line)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
entry["last_error"] = f"reader error: {e}"
|
|
162
|
+
finally:
|
|
163
|
+
rc = proc.poll()
|
|
164
|
+
if rc not in (None, 0):
|
|
165
|
+
entry["last_error"] = (
|
|
166
|
+
entry.get("last_error")
|
|
167
|
+
or f"exited rc={rc}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
t = threading.Thread(target=_reader, daemon=True)
|
|
171
|
+
t.start()
|
|
172
|
+
entry["reader"] = t
|
|
173
|
+
|
|
174
|
+
_servers[project_id] = entry
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"running": True,
|
|
178
|
+
"pid": proc.pid,
|
|
179
|
+
"port": port,
|
|
180
|
+
"url": f"http://localhost:{port}",
|
|
181
|
+
"command": cmd_str,
|
|
182
|
+
"started_at": entry["started_at"],
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def stop(project_id: int) -> bool:
|
|
187
|
+
"""Terminate the dev server for the project."""
|
|
188
|
+
entry = _servers.pop(project_id, None)
|
|
189
|
+
if entry is None:
|
|
190
|
+
return False
|
|
191
|
+
proc: subprocess.Popen = entry["process"]
|
|
192
|
+
try:
|
|
193
|
+
# Kill the whole process group — npm spawns child processes
|
|
194
|
+
# (next.js, vite, etc.) that need to die with it.
|
|
195
|
+
try:
|
|
196
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
197
|
+
except (ProcessLookupError, PermissionError):
|
|
198
|
+
pass
|
|
199
|
+
try:
|
|
200
|
+
proc.wait(timeout=5)
|
|
201
|
+
except subprocess.TimeoutExpired:
|
|
202
|
+
try:
|
|
203
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
204
|
+
except (ProcessLookupError, PermissionError):
|
|
205
|
+
pass
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.warning(f"stop dev server failed: {e}")
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def stop_all() -> int:
|
|
212
|
+
"""Shut down every running dev server. Called by api.server's shutdown
|
|
213
|
+
hook so we don't orphan dev processes when cognos-serve restarts."""
|
|
214
|
+
n = 0
|
|
215
|
+
for pid in list(_servers):
|
|
216
|
+
if stop(pid):
|
|
217
|
+
n += 1
|
|
218
|
+
return n
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = ["start", "stop", "stop_all", "status"]
|