@brandon_9527/tcode 1.0.7 → 1.0.9
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.
- package/dist/python-src/.env +5 -3
- package/dist/python-src/README.md +40 -1
- package/dist/python-src/_workspace/.autodev/config.json +12 -0
- package/dist/python-src/_workspace/.autodev/cron/jobs.json +4 -0
- package/dist/python-src/entry.py +35 -1
- package/dist/python-src/main.py +753 -40
- package/dist/python-src/pyproject.toml +1 -0
- package/dist/python-src/run.sh +9 -0
- package/dist/python-src/src/agents/token_tracker.py +4 -4
- package/dist/python-src/src/claw/bus/queue.py +1 -1
- package/dist/python-src/src/claw/channels/__init__.py +2 -2
- package/dist/python-src/src/claw/channels/base.py +2 -2
- package/dist/python-src/src/claw/channels/feishu.py +57 -16
- package/dist/python-src/src/claw/channels/manager.py +2 -2
- package/dist/python-src/src/claw/config/__init__.py +3 -0
- package/dist/python-src/src/claw/config/loader.py +38 -0
- package/dist/python-src/src/claw/config/schema.py +14 -29
- package/dist/python-src/src/claw/cron/__init__.py +3 -0
- package/dist/python-src/src/claw/cron/service.py +171 -0
- package/dist/python-src/src/claw/cron/types_.py +14 -0
- package/dist/python-src/src/claw/heartbeat/__init__.py +2 -0
- package/dist/python-src/src/claw/heartbeat/service.py +55 -0
- package/dist/python-src/src/claw/run.py +82 -0
- package/dist/python-src/src/claw/tools/base.py +23 -0
- package/dist/python-src/src/claw/tools/channel.py +0 -0
- package/dist/python-src/src/claw/tools/cron.py +138 -0
- package/dist/python-src/src/claw/utils/__init__.py +2 -0
- package/dist/python-src/src/claw/utils/helpers.py +27 -0
- package/dist/python-src/src/core/context.py +158 -0
- package/dist/python-src/src/core/deepagents.py +5 -5
- package/dist/python-src/src/managers/manager_agent.py +9 -9
- package/dist/python-src/src/managers/manager_command.py +62 -0
- package/dist/python-src/src/managers/manager_context.py +1 -1
- package/dist/python-src/src/managers/manager_instruction.py +7 -7
- package/dist/python-src/src/managers/manager_skill.py +3 -3
- package/dist/python-src/src/managers/sandbox.py +3 -3
- package/dist/python-src/src/middlewares/dynamic_content.py +2 -2
- package/dist/python-src/src/middlewares/hitl.py +3 -3
- package/dist/python-src/src/middlewares/memory.py +2 -2
- package/dist/python-src/src/middlewares/subagents.py +4 -4
- package/dist/python-src/src/middlewares/summary.py +37 -37
- package/dist/python-src/src/stream/file_write_parser.py +3 -3
- package/dist/python-src/src/stream/formatter.py +19 -19
- package/dist/python-src/src/stream/handler.py +4 -4
- package/dist/python-src/src/stream/handler_with_tracker.py +10 -10
- package/dist/python-src/src/trackers/token/pricing.py +2 -2
- package/dist/python-src/src/trackers/token/report.py +4 -4
- package/dist/python-src/src/trackers/token/tracker.py +8 -8
- package/dist/python-src/src/tui/chatui.py +10 -10
- package/dist/python-src/src/tui/clawtui.py +224 -0
- package/dist/python-src/src/tui/commands/__init__.py +3 -0
- package/dist/python-src/src/tui/commands/base.py +6 -0
- package/dist/python-src/src/tui/commands/instruction.py +5 -0
- package/dist/python-src/src/tui/components/tlist.py +7 -7
- package/dist/python-src/src/tui/components/tscroll_panel.py +73 -44
- package/dist/python-src/src/tui/components/tscroll_panel_old.py +58 -0
- package/dist/python-src/src/tui/utils/trender.py +21 -21
- package/dist/python-src/uv.lock +1969 -1958
- package/package.json +1 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# 获取当前脚本所在目录的绝对路径
|
|
2
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
3
|
+
|
|
4
|
+
# 使用该目录下的 .venv/bin/python 运行 cli.py
|
|
5
|
+
# "$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/src/agents/skill_agent.py"
|
|
6
|
+
|
|
7
|
+
# "$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/main.py"
|
|
8
|
+
|
|
9
|
+
"$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/entry.py" "$@"
|
|
@@ -24,11 +24,11 @@ class TokenUsageMiddleware(AgentMiddleware):
|
|
|
24
24
|
from langchain.agents import create_agent
|
|
25
25
|
from langchain.chat_models import init_chat_model
|
|
26
26
|
def main():B=get_default_model(streaming=_B);C=[];D=_G;E=create_agent(model=B,tools=C,system_prompt=D,middleware=[TokenUsageMiddleware()]);A='请介绍一下LangChain v1的新特性';A='你好';E.invoke({_A:[{'role':'user',_H:A}]})
|
|
27
|
-
def
|
|
27
|
+
def dr(token):
|
|
28
28
|
A=token
|
|
29
29
|
if A.text:print(A.text,end='|')
|
|
30
30
|
if A.tool_call_chunks:print(A.tool_call_chunks)
|
|
31
|
-
def
|
|
31
|
+
def dq(message):
|
|
32
32
|
A=message
|
|
33
33
|
if isinstance(A,AIMessage)and A.tool_calls:print(f"Tool calls: {A.tool_calls}")
|
|
34
34
|
if isinstance(A,ToolMessage):print(f"Tool response: {A.content_blocks}")
|
|
@@ -37,8 +37,8 @@ async def amain():
|
|
|
37
37
|
async for A in I.astream({_A:[J]},stream_mode=[_A,C],version='v2'):
|
|
38
38
|
if A[D]==_A:
|
|
39
39
|
B,M=A[E]
|
|
40
|
-
if isinstance(B,AIMessageChunk):
|
|
40
|
+
if isinstance(B,AIMessageChunk):dr(B)
|
|
41
41
|
elif A[D]==C:
|
|
42
42
|
for(K,L)in A[E].items():
|
|
43
|
-
if K in('model','tools'):
|
|
43
|
+
if K in('model','tools'):dq(L[_A][-1])
|
|
44
44
|
if __name__=='__main__':import asyncio;asyncio.run(amain())
|
|
@@ -2,7 +2,7 @@ _A=False
|
|
|
2
2
|
import asyncio
|
|
3
3
|
from typing import Callable,Awaitable
|
|
4
4
|
from logging import getLogger
|
|
5
|
-
from src.
|
|
5
|
+
from src.claw.bus.events import InboundMessage,OutboundMessage
|
|
6
6
|
logger=getLogger(__name__)
|
|
7
7
|
class MessageBus:
|
|
8
8
|
def __init__(A):A.inbound=asyncio.Queue();A.outbound=asyncio.Queue();A._outbound_subscribers={};A._inbound_subscribers={};A._in_running=_A;A._out_running=_A
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
from src.
|
|
2
|
-
from src.
|
|
1
|
+
from src.claw.channels.base import BaseChannel
|
|
2
|
+
from src.claw.channels.manager import ChannelManager
|
|
3
3
|
__all__=['BaseChannel','ChannelManager']
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from abc import ABC,abstractmethod
|
|
2
2
|
from typing import Any
|
|
3
3
|
from logging import getLogger
|
|
4
|
-
from src.
|
|
5
|
-
from src.
|
|
4
|
+
from src.claw.bus.events import InboundMessage,OutboundMessage
|
|
5
|
+
from src.claw.bus.queue import MessageBus
|
|
6
6
|
logger=getLogger(__name__)
|
|
7
7
|
class BaseChannel(ABC):
|
|
8
8
|
name:str='base'
|
|
@@ -1,27 +1,33 @@
|
|
|
1
|
+
_G='open_id'
|
|
2
|
+
_F='chat_id'
|
|
1
3
|
_E='THUMBSUP'
|
|
2
4
|
_D=False
|
|
3
5
|
_C=True
|
|
4
6
|
_B='tag'
|
|
5
7
|
_A=None
|
|
6
|
-
import asyncio,json,re,threading
|
|
8
|
+
import asyncio,json,re,os,threading
|
|
7
9
|
from collections import OrderedDict
|
|
8
10
|
from typing import Any
|
|
9
11
|
from logging import getLogger
|
|
10
12
|
logger=getLogger(__name__)
|
|
11
|
-
from src.
|
|
12
|
-
from src.
|
|
13
|
-
from src.
|
|
14
|
-
from src.
|
|
13
|
+
from src.claw.bus.events import InboundMessage,OutboundMessage
|
|
14
|
+
from src.claw.bus.queue import MessageBus
|
|
15
|
+
from src.claw.channels.base import BaseChannel
|
|
16
|
+
from src.claw.config.schema import FeishuConfig
|
|
15
17
|
try:import lark_oapi as lark;from lark_oapi.api.im.v1 import CreateMessageRequest,CreateMessageRequestBody,CreateMessageReactionRequest,CreateMessageReactionRequestBody,Emoji,P2ImMessageReceiveV1;FEISHU_AVAILABLE=_C
|
|
16
18
|
except ImportError:FEISHU_AVAILABLE=_D;lark=_A;Emoji=_A
|
|
17
19
|
MSG_TYPE_MAP={'image':'[image]','audio':'[audio]','file':'[file]','sticker':'[sticker]'}
|
|
20
|
+
_IMAGE_EXTS={'.png','.jpg','.jpeg','.gif','.bmp','.webp','.ico','.tiff','.tif'}
|
|
21
|
+
_AUDIO_EXTS={'.opus'}
|
|
22
|
+
_VIDEO_EXTS={'.mp4','.mov','.avi'}
|
|
23
|
+
_FILE_TYPE_MAP={'.opus':'opus','.mp4':'mp4','.pdf':'pdf','.doc':'doc','.docx':'doc','.xls':'xls','.xlsx':'xls','.ppt':'ppt','.pptx':'ppt'}
|
|
18
24
|
class FeishuChannel(BaseChannel):
|
|
19
25
|
name='feishu'
|
|
20
26
|
def __init__(A,config,bus):B=config;super().__init__(B,bus);A.config=B;A._client=_A;A._ws_client=_A;A._ws_thread=_A;A._processed_message_ids=OrderedDict();A._loop=_A
|
|
21
27
|
async def start(A):
|
|
22
28
|
if not FEISHU_AVAILABLE:logger.error('Feishu SDK not installed. Run: pip install lark-oapi');return
|
|
23
29
|
if not A.config.app_id or not A.config.app_secret:logger.error('Feishu app_id and app_secret not configured');return
|
|
24
|
-
A._running=_C;A._loop=asyncio.get_running_loop();A._client=lark.Client.builder().app_id(A.config.app_id).app_secret(A.config.app_secret).log_level(lark.LogLevel.
|
|
30
|
+
A._running=_C;A._loop=asyncio.get_running_loop();A._client=lark.Client.builder().app_id(A.config.app_id).app_secret(A.config.app_secret).log_level(lark.LogLevel.CRITICAL).build();B=lark.EventDispatcherHandler.builder(A.config.encrypt_key or'',A.config.verification_token or'').register_p2_im_message_receive_v1(A.em).build();A._ws_client=lark.ws.Client(A.config.app_id,A.config.app_secret,event_handler=B,log_level=lark.LogLevel.CRITICAL)
|
|
25
31
|
def C():
|
|
26
32
|
try:A._ws_client.start()
|
|
27
33
|
except Exception as B:logger.error(f"Feishu WebSocket error: {B}")
|
|
@@ -33,7 +39,7 @@ class FeishuChannel(BaseChannel):
|
|
|
33
39
|
try:A._ws_client.stop()
|
|
34
40
|
except Exception as B:logger.warning(f"Error stoppping WebSocket client: {B}")
|
|
35
41
|
logger.info('Feishu bot stopped')
|
|
36
|
-
def
|
|
42
|
+
def el(D,message_id,emoji_type):
|
|
37
43
|
C=emoji_type;B=message_id
|
|
38
44
|
try:
|
|
39
45
|
E=CreateMessageReactionRequest.builder().message_id(B).request_body(CreateMessageReactionRequestBody.builder().reaction_type(Emoji.builder().emoji_type(C).build()).build()).build();A=D._client.im.v1.message_reaction.create(E)
|
|
@@ -42,19 +48,19 @@ class FeishuChannel(BaseChannel):
|
|
|
42
48
|
except Exception as F:logger.warning(f"Error adding reaction: {F}")
|
|
43
49
|
async def _add_reaction(A,message_id,emoji_type=_E):
|
|
44
50
|
if not A._client or not Emoji:return
|
|
45
|
-
B=asyncio.get_running_loop();await B.run_in_executor(_A,A.
|
|
51
|
+
B=asyncio.get_running_loop();await B.run_in_executor(_A,A.el,message_id,emoji_type)
|
|
46
52
|
_TABLE_RE=re.compile('((?:^[ \\t]*\\|.+\\|[ \\t]*\\n)(?:^[ \\t]*\\|[-:\\s|]+\\|[ \\t]*\\n)(?:^[ \\t]*\\|.+\\|[ \\t]*\\n?)+)',re.MULTILINE)
|
|
47
53
|
@staticmethod
|
|
48
|
-
def
|
|
54
|
+
def eo(table_text):
|
|
49
55
|
A=[A.strip()for A in table_text.strip().split('\n')if A.strip()]
|
|
50
56
|
if len(A)<3:return
|
|
51
57
|
B=lambda l:[A.strip()for A in l.strip('|').split('|')];C=B(A[0]);D=[B(A)for A in A[2:]];E=[{_B:'column','name':f"c{A}",'display_name':B,'width':'auto'}for(A,B)in enumerate(C)];return{_B:'table','page_size':len(D)+1,'columns':E,'rows':[{f"c{A}":B[A]if A<len(B)else''for A in range(len(C))}for B in D]}
|
|
52
|
-
def
|
|
58
|
+
def ej(G,content):
|
|
53
59
|
E='markdown';D='content';A=content;B,F=[],0
|
|
54
60
|
for C in G._TABLE_RE.finditer(A):
|
|
55
61
|
H=A[F:C.start()].strip()
|
|
56
62
|
if H:B.append({_B:E,D:H})
|
|
57
|
-
B.append(G.
|
|
63
|
+
B.append(G.eo(C.group(1))or{_B:E,D:C.group(1)});F=C.end()
|
|
58
64
|
I=A[F:].strip()
|
|
59
65
|
if I:B.append({_B:E,D:I})
|
|
60
66
|
return B or[{_B:E,D:A}]
|
|
@@ -62,13 +68,32 @@ class FeishuChannel(BaseChannel):
|
|
|
62
68
|
A=msg
|
|
63
69
|
if not C._client:logger.warning('Feishu client not initialized');return
|
|
64
70
|
try:
|
|
65
|
-
if A.chat_id.startswith('oc_'):D=
|
|
66
|
-
else:D=
|
|
67
|
-
E=C.
|
|
71
|
+
if A.chat_id.startswith('oc_'):D=_F
|
|
72
|
+
else:D=_G
|
|
73
|
+
E=C.ej(A.content);F={'config':{'wide_screen_mode':_C},'elements':E};G=json.dumps(F,ensure_ascii=_D);H=CreateMessageRequest.builder().receive_id_type(D).request_body(CreateMessageRequestBody.builder().receive_id(A.chat_id).msg_type('interactive').content(G).build()).build();B=C._client.im.v1.message.create(H)
|
|
68
74
|
if not B.success():logger.error(f"Failed to send Feishu message: code={B.code},msg={B.msg}, log_id={B.get_log_id()}")
|
|
69
75
|
else:logger.debug(f"Feishu message sent to {A.chat_id}")
|
|
70
76
|
except Exception as I:logger.error(f"Error sending Feishu message: {I}")
|
|
71
|
-
def
|
|
77
|
+
async def send_message(D,chat_id,msg_type,content):
|
|
78
|
+
B=chat_id
|
|
79
|
+
if B.startswith('oc_'):C=_F
|
|
80
|
+
else:C=_G
|
|
81
|
+
E=CreateMessageRequest.builder().receive_id_type(C).request_body(CreateMessageRequestBody.builder().receive_id(B).msg_type(msg_type).content(content).build()).build();A=D._client.im.v1.message.create(E)
|
|
82
|
+
if A.success():print(f"Feishu message sent to {B} successfully")
|
|
83
|
+
else:print(f"Failed to send Feishu message: code={A.code}, msg={A.msg}, log_id={A.get_log_id()}")
|
|
84
|
+
async def send_image(A,chat_id,image_path):
|
|
85
|
+
B=A.en(image_path)
|
|
86
|
+
if B is _A:return
|
|
87
|
+
A.send_message(chat_id,'image',json.dumps({'image_key':B}))
|
|
88
|
+
async def send_file(B,chat_id,file_path):
|
|
89
|
+
C=file_path;D=B.ek(C)
|
|
90
|
+
if D is _A:return
|
|
91
|
+
E=os.path.splitext(C)[1].lower()
|
|
92
|
+
if E in _AUDIO_EXTS:A='audio'
|
|
93
|
+
elif E in _VIDEO_EXTS:A='video'
|
|
94
|
+
else:A='file'
|
|
95
|
+
B.send_message(chat_id,A,json.dumps({'file_key':D}))
|
|
96
|
+
def em(A,data):
|
|
72
97
|
if A._loop and A._loop.is_running():asyncio.run_coroutine_threadsafe(A._on_message(data),A._loop)
|
|
73
98
|
async def _on_message(A,data):
|
|
74
99
|
J='text'
|
|
@@ -86,4 +111,20 @@ class FeishuChannel(BaseChannel):
|
|
|
86
111
|
else:E=MSG_TYPE_MAP.get(D,f"[{D}]")
|
|
87
112
|
if not E:return
|
|
88
113
|
M=L if I=='group'else H;await A._handle_message(sender_id=H,chat_id=M,content=E,metadata={'message_id':C,'chat_type':I,'msg_type':D})
|
|
89
|
-
except Exception as N:logger.error(f"Error processing Feishu message: {N}")
|
|
114
|
+
except Exception as N:logger.error(f"Error processing Feishu message: {N}")
|
|
115
|
+
def en(D,file_path):
|
|
116
|
+
B=file_path;from lark_oapi.api.im.v1 import CreateImageRequest as E,CreateImageRequestBody as F
|
|
117
|
+
try:
|
|
118
|
+
with open(B,'rb')as G:
|
|
119
|
+
H=E.builder().request_body(F.builder().image_type('message').image(G).build()).build();A=D._client.im.v1.image.create(H)
|
|
120
|
+
if A.success():C=A.data.image_key;print(f"Uploaded image: {os.path.basename(B)}, {C}");return C
|
|
121
|
+
else:print(f"Failed to upload image: {A.code} {A.msg}");return
|
|
122
|
+
except Exception:print(f"Error uploading image {B}");return
|
|
123
|
+
def ek(D,file_path):
|
|
124
|
+
A=file_path;from lark_oapi.api.im.v1 import CreateFileRequest as E,CreateFileRequestBody as F;G=os.path.splitext(A)[1].lower();H=_FILE_TYPE_MAP.get(G,'stream');I=os.path.basename(A)
|
|
125
|
+
try:
|
|
126
|
+
with open(A,'rb')as J:
|
|
127
|
+
K=E.builder().request_body(F.builder().file_type(H).file_name(I).file(J).build()).build();B=D._client.im.v1.file.create(K)
|
|
128
|
+
if B.success():C=B.data.file_key;print(f"Uploaded file: {os.path.basename(A)}, {C}");return C
|
|
129
|
+
else:print(f"Failed to upload file: {B.code} {B.msg}");return
|
|
130
|
+
except Exception:print(f"Error uploading file {A}");return
|
|
@@ -8,8 +8,8 @@ from src.claw.channels.base import BaseChannel
|
|
|
8
8
|
from src.claw.config.schema import Config
|
|
9
9
|
logger=getLogger(__name__)
|
|
10
10
|
class ChannelManager:
|
|
11
|
-
def __init__(A,config,bus):A.config=config;A.bus=bus;A.channels={};A._dispatch_task=None;A.
|
|
12
|
-
def
|
|
11
|
+
def __init__(A,config,bus):A.config=config;A.bus=bus;A.channels={};A._dispatch_task=None;A.ep()
|
|
12
|
+
def ep(A):
|
|
13
13
|
if A.config.channels.feishu.enabled:
|
|
14
14
|
try:from src.claw.channels.feishu import FeishuChannel as B;A.channels['feishu']=B(A.config.channels.feishu,A.bus);logger.info('Feishu channel enabled')
|
|
15
15
|
except ImportError as C:logger.warning(f"Feishu channel not available: {C}")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
import json
|
|
4
|
+
from src.claw.utils.helpers import get_data_path
|
|
5
|
+
from src.claw.config.schema import Config
|
|
6
|
+
def get_config_path():return Path.home()/'.autodev'/'config.json'
|
|
7
|
+
def get_data_dir():return get_data_path()
|
|
8
|
+
def load_config(config_path=None):
|
|
9
|
+
A=config_path or get_config_path()
|
|
10
|
+
if A.exists():
|
|
11
|
+
try:
|
|
12
|
+
with open(A)as C:B=json.load(C)
|
|
13
|
+
B=dz(B);return Config.model_validate(convert_keys(B))
|
|
14
|
+
except(json.JSONDecodeError,ValueError)as D:print(f"Warning: Failed to load config from {A}: {D}");print('Using default configuration.')
|
|
15
|
+
return Config()
|
|
16
|
+
def save_config(config,config_path=None):
|
|
17
|
+
B=config_path or get_config_path();B.parent.mkdir(parents=True,exist_ok=True);A=config.model_dump();A=convert_to_camel(A)
|
|
18
|
+
with open(B,'w')as C:json.dump(A,C,indent=2)
|
|
19
|
+
def dz(data):
|
|
20
|
+
A='restrictToWorkspace';B=data.get('tools',{});C=B.get('exec',{})
|
|
21
|
+
if A in C and A not in B:B[A]=C.pop(A)
|
|
22
|
+
return data
|
|
23
|
+
def convert_keys(data):
|
|
24
|
+
A=data
|
|
25
|
+
if isinstance(A,dict):return{camel_to_snake(A):convert_keys(B)for(A,B)in A.items()}
|
|
26
|
+
if isinstance(A,list):return[convert_keys(A)for A in A]
|
|
27
|
+
return A
|
|
28
|
+
def convert_to_camel(data):
|
|
29
|
+
A=data
|
|
30
|
+
if isinstance(A,dict):return{snake_to_camel(A):convert_to_camel(B)for(A,B)in A.items()}
|
|
31
|
+
if isinstance(A,list):return[convert_to_camel(A)for A in A]
|
|
32
|
+
def camel_to_snake(name):
|
|
33
|
+
A=[]
|
|
34
|
+
for(C,B)in enumerate(name):
|
|
35
|
+
if B.isupper()and C>0:A.append('_')
|
|
36
|
+
A.append(B.lower())
|
|
37
|
+
return''.join(A)
|
|
38
|
+
def snake_to_camel(name):A=name.split('_');return A[0]+''.join(A.title()for A in A[1:])
|
|
@@ -1,46 +1,31 @@
|
|
|
1
1
|
_C=True
|
|
2
|
-
_B=
|
|
3
|
-
_A=
|
|
2
|
+
_B=None
|
|
3
|
+
_A=False
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from pydantic import BaseModel,Field
|
|
6
6
|
from pydantic_settings import BaseSettings
|
|
7
|
-
class FeishuConfig(BaseModel):enabled:bool=
|
|
8
|
-
class DingTalkConfig(BaseModel):enabled:bool=
|
|
9
|
-
class EmailConfig(BaseModel):enabled:bool=
|
|
10
|
-
class MochatMentionConfig(BaseModel):require_in_groups:bool=
|
|
11
|
-
class MochatGroupRule(BaseModel):require_mention:bool=
|
|
12
|
-
class MochatConfig(BaseModel):enabled:bool=
|
|
13
|
-
class QQConfig(BaseModel):enabled:bool=
|
|
7
|
+
class FeishuConfig(BaseModel):enabled:bool=_A;app_id:str='';app_secret:str='';encrypt_key:str='';verification_token:str='';allow_from:list[str]=Field(default_factory=list)
|
|
8
|
+
class DingTalkConfig(BaseModel):enabled:bool=_A;client_id:str='';client_secret:str='';allow_from:list[str]=Field(default_factory=list)
|
|
9
|
+
class EmailConfig(BaseModel):enabled:bool=_A;consent_granted:bool=_A;imap_host:str='';imap_port:int=993;imap_username:str='';imap_password:str='';imap_mailbox:str='INBOX';imap_use_ssl:bool=_C;smtp_host:str='';smtp_port:int=587;smtp_username:str='';smtp_password:str='';smtp_use_tls:bool=_C;smtp_use_ssl:bool=_A;from_address:str='';auto_reply_enabled:bool=_C;poll_interval_seconds:int=30;mark_seen:bool=_C;max_body_chars:int=12000;subject_prefix:str='Re: ';allow_from:list[str]=Field(default_factory=list)
|
|
10
|
+
class MochatMentionConfig(BaseModel):require_in_groups:bool=_A
|
|
11
|
+
class MochatGroupRule(BaseModel):require_mention:bool=_A
|
|
12
|
+
class MochatConfig(BaseModel):enabled:bool=_A;base_url:str='https://mochat.io';socket_url:str='';socket_path:str='/socket.io';socket_disable_msgpack:bool=_A;socket_reconnect_delay_ms:int=1000;socket_max_reconnect_delay_ms:int=10000;socket_connect_timeout_ms:int=10000;refresh_interval_ms:int=30000;watch_timeout_ms:int=25000;watch_limit:int=100;retry_delay_ms:int=500;max_retry_attempts:int=0;claw_token:str='';agent_user_id:str='';sessions:list[str]=Field(default_factory=list);panels:list[str]=Field(default_factory=list);allow_from:list[str]=Field(default_factory=list);mention:MochatMentionConfig=Field(default_factory=MochatMentionConfig);groups:dict[str,MochatGroupRule]=Field(default_factory=dict);reply_delay_mode:str='non-mention';reply_delay_ms:int=120000
|
|
13
|
+
class QQConfig(BaseModel):enabled:bool=_A;app_id:str='';secret:str='';allow_from:list[str]=Field(default_factory=list)
|
|
14
14
|
class ChannelsConfig(BaseModel):feishu:FeishuConfig=Field(default_factory=FeishuConfig);mochat:MochatConfig=Field(default_factory=MochatConfig);dingtalk:DingTalkConfig=Field(default_factory=DingTalkConfig);email:EmailConfig=Field(default_factory=EmailConfig);qq:QQConfig=Field(default_factory=QQConfig)
|
|
15
15
|
class AgentDefaults(BaseModel):workspace:str='~/.nanobot/workspace';model:str='qwen-plus';max_tokens:int=8192;temperature:float=.7;max_tool_iterations:int=200
|
|
16
16
|
class AgentsConfig(BaseModel):defaults:AgentDefaults=Field(default_factory=AgentDefaults)
|
|
17
|
-
class ProviderConfig(BaseModel):api_key:str='';api_base:str|
|
|
17
|
+
class ProviderConfig(BaseModel):api_key:str='';api_base:str|_B=_B;extra_headers:dict[str,str]|_B=_B
|
|
18
18
|
class ProvidersConfig(BaseModel):anthropic:ProviderConfig=Field(default_factory=ProviderConfig);openai:ProviderConfig=Field(default_factory=ProviderConfig);openrouter:ProviderConfig=Field(default_factory=ProviderConfig);deepseek:ProviderConfig=Field(default_factory=ProviderConfig);groq:ProviderConfig=Field(default_factory=ProviderConfig);zhipu:ProviderConfig=Field(default_factory=ProviderConfig);dashscope:ProviderConfig=Field(default_factory=ProviderConfig);vllm:ProviderConfig=Field(default_factory=ProviderConfig);gemini:ProviderConfig=Field(default_factory=ProviderConfig);moonshot:ProviderConfig=Field(default_factory=ProviderConfig);minimax:ProviderConfig=Field(default_factory=ProviderConfig);aihubmix:ProviderConfig=Field(default_factory=ProviderConfig)
|
|
19
19
|
class GatewayConfig(BaseModel):host:str='0.0.0.0';port:int=18790
|
|
20
20
|
class WebSearchConfig(BaseModel):api_key:str='';max_results:int=5
|
|
21
21
|
class WebToolsConfig(BaseModel):search:WebSearchConfig=Field(default_factory=WebSearchConfig)
|
|
22
22
|
class ExecToolConfig(BaseModel):timeout:int=60
|
|
23
|
-
class ToolsConfig(BaseModel):web:WebToolsConfig=Field(default_factory=WebToolsConfig);exec:ExecToolConfig=Field(default_factory=ExecToolConfig);restrict_to_workspace:bool=
|
|
23
|
+
class ToolsConfig(BaseModel):web:WebToolsConfig=Field(default_factory=WebToolsConfig);exec:ExecToolConfig=Field(default_factory=ExecToolConfig);restrict_to_workspace:bool=_A
|
|
24
24
|
class Config(BaseSettings):
|
|
25
25
|
agents:AgentsConfig=Field(default_factory=AgentsConfig);channels:ChannelsConfig=Field(default_factory=ChannelsConfig);providers:ProvidersConfig=Field(default_factory=ProvidersConfig);gateway:GatewayConfig=Field(default_factory=GatewayConfig);tools:ToolsConfig=Field(default_factory=ToolsConfig)
|
|
26
26
|
@property
|
|
27
27
|
def workspace_path(self):return Path(self.agents.defaults.workspace).expanduser()
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
A=getattr(C.providers,B.name,_A)
|
|
32
|
-
if A and any(A in E for A in B.keywords)and A.api_key:return A,B.name
|
|
33
|
-
for B in D:
|
|
34
|
-
A=getattr(C.providers,B.name,_A)
|
|
35
|
-
if A and A.api_key:return A,B.name
|
|
36
|
-
return _A,_A
|
|
37
|
-
def get_provider(A,model=_A):B,C=A.df(model);return B
|
|
38
|
-
def get_provider_name(A,model=_A):C,B=A.df(model);return B
|
|
39
|
-
def get_api_key(B,model=_A):A=B.get_provider(model);return A.api_key if A else _A
|
|
40
|
-
def get_api_base(D,model=_A):
|
|
41
|
-
from nanobot.providers.registry import find_by_name as E;B,C=D.df(model)
|
|
42
|
-
if B and B.api_base:return B.api_base
|
|
43
|
-
if C:
|
|
44
|
-
A=E(C)
|
|
45
|
-
if A and A.is_gateway and A.default_api_base:return A.default_api_base
|
|
28
|
+
def get_provider(A,model=_B):B,C=A._match_provider(model);return B
|
|
29
|
+
def get_provider_name(A,model=_B):C,B=A._match_provider(model);return B
|
|
30
|
+
def get_api_key(B,model=_B):A=B.get_provider(model);return A.api_key if A else _B
|
|
46
31
|
class Config:env_prefix='NANOBOT_';env_nested_delimiter='__'
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
_X='deleteAfterRun'
|
|
2
|
+
_W='updatedAtMs'
|
|
3
|
+
_V='createdAtMs'
|
|
4
|
+
_U='lastError'
|
|
5
|
+
_T='lastStatus'
|
|
6
|
+
_S='lastRunAtMs'
|
|
7
|
+
_R='nextRunAtMs'
|
|
8
|
+
_Q='channel'
|
|
9
|
+
_P='deliver'
|
|
10
|
+
_O='message'
|
|
11
|
+
_N='agent_turn'
|
|
12
|
+
_M='everyMs'
|
|
13
|
+
_L='enabled'
|
|
14
|
+
_K='jobs'
|
|
15
|
+
_J='cron'
|
|
16
|
+
_I='every'
|
|
17
|
+
_H='kind'
|
|
18
|
+
_G='at'
|
|
19
|
+
_F='state'
|
|
20
|
+
_E='payload'
|
|
21
|
+
_D='schedule'
|
|
22
|
+
_C=None
|
|
23
|
+
_B=True
|
|
24
|
+
_A=False
|
|
25
|
+
import asyncio,json,time,uuid
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any,Callable,Coroutine,Optional
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from logging import getLogger
|
|
30
|
+
import logging
|
|
31
|
+
logging.basicConfig(level=logging.CRITICAL+1,format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',handlers=[logging.StreamHandler()])
|
|
32
|
+
logger=getLogger(__name__)
|
|
33
|
+
import logging
|
|
34
|
+
logger.setLevel(logging.CRITICAL+1)
|
|
35
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
36
|
+
from apscheduler.executors.asyncio import AsyncIOExecutor
|
|
37
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
38
|
+
import sys,os
|
|
39
|
+
sys.path.append(os.getcwd())
|
|
40
|
+
from src.claw.cron.types_ import CronJob,CronJobState,CronPayload,CronSchedule,CronStore
|
|
41
|
+
def ed():return int(time.time()*1000)
|
|
42
|
+
def eg(schedule,now_ms):
|
|
43
|
+
B=now_ms;A=schedule
|
|
44
|
+
if A.kind==_G:return A.at_ms if A.at_ms and A.at_ms>B else _C
|
|
45
|
+
if A.kind==_I:
|
|
46
|
+
if not A.every_ms or A.every_ms<=0:return
|
|
47
|
+
return B+A.every_ms
|
|
48
|
+
if A.kind==_J and A.expr:
|
|
49
|
+
try:from croniter import croniter as C;D=C(A.expr,time.time());E=D.get_next();return int(E*1000)
|
|
50
|
+
except Exception as F:logger.error(f"解析cron表达式失败: {F}");return
|
|
51
|
+
class CronService:
|
|
52
|
+
def __init__(A,store_path,on_job=_C):A.store_path=store_path;A.on_job=on_job;A._store=_C;B={'default':AsyncIOExecutor()};C={'coalesce':_A,'max_instances':1};A._scheduler=AsyncIOScheduler(executors=B,job_defaults=C);A._running=_A
|
|
53
|
+
def ee(B):
|
|
54
|
+
if B._store:return B._store
|
|
55
|
+
if B.store_path.exists():
|
|
56
|
+
try:
|
|
57
|
+
D=json.loads(B.store_path.read_text());C=[]
|
|
58
|
+
for A in D.get(_K,[]):C.append(CronJob(id=A['id'],name=A['name'],enabled=A.get(_L,_B),schedule=CronSchedule(kind=A[_D][_H],at_ms=A[_D].get('atMs'),every_ms=A[_D].get(_M),expr=A[_D].get('expr'),tz=A[_D].get('tz')),payload=CronPayload(kind=A[_E].get(_H,_N),message=A[_E].get(_O,''),deliver=A[_E].get(_P,_A),channel=A[_E].get(_Q),to=A[_E].get('to')),state=CronJobState(next_run_at_ms=A.get(_F,{}).get(_R),last_run_at_ms=A.get(_F,{}).get(_S),last_status=A.get(_F,{}).get(_T),last_error=A.get(_F,{}).get(_U)),created_at_ms=A.get(_V,0),updated_at_ms=A.get(_W,0),delete_after_run=A.get(_X,_A)))
|
|
59
|
+
B._store=CronStore(jobs=C)
|
|
60
|
+
except Exception as E:logger.warning(f"Failed to load cron store: {E}");B._store=CronStore()
|
|
61
|
+
else:B._store=CronStore()
|
|
62
|
+
return B._store
|
|
63
|
+
def eh(A):
|
|
64
|
+
if not A._store:return
|
|
65
|
+
A.store_path.parent.mkdir(parents=_B,exist_ok=_B);B={'version':A._store.version,_K:[{'id':A.id,'name':A.name,_L:A.enabled,_D:{_H:A.schedule.kind,'atMs':A.schedule.at_ms,_M:A.schedule.every_ms,'expr':A.schedule.expr,'tz':A.schedule.tz},_E:{_H:A.payload.kind,_O:A.payload.message,_P:A.payload.deliver,_Q:A.payload.channel,'to':A.payload.to},_F:{_R:A.state.next_run_at_ms,_S:A.state.last_run_at_ms,_T:A.state.last_status,_U:A.state.last_error},_V:A.created_at_ms,_W:A.updated_at_ms,_X:A.delete_after_run}for A in A._store.jobs]};A.store_path.write_text(json.dumps(B,indent=2))
|
|
66
|
+
async def start(A):
|
|
67
|
+
A._running=_B;A.ee();A.ef();A.eh()
|
|
68
|
+
if not A._scheduler.running:A._scheduler.start();logger.info('APScheduler调度器已启动')
|
|
69
|
+
for B in A._store.jobs:
|
|
70
|
+
if B.enabled:A.ei(B)
|
|
71
|
+
logger.info(f"Cron service started with {len(A._store.jobs if A._store else[])} jobs")
|
|
72
|
+
async def stop(A):
|
|
73
|
+
A._running=_A
|
|
74
|
+
if A._scheduler.running:A._scheduler.shutdown()
|
|
75
|
+
logger.info('Cron service stopped')
|
|
76
|
+
def ef(B):
|
|
77
|
+
if not B._store:return
|
|
78
|
+
C=ed()
|
|
79
|
+
for A in B._store.jobs:
|
|
80
|
+
if A.enabled:A.state.next_run_at_ms=eg(A.schedule,C)
|
|
81
|
+
def ec(A):
|
|
82
|
+
if not A._store:return
|
|
83
|
+
B=[A.state.next_run_at_ms for A in A._store.jobs if A.enabled and A.state.next_run_at_ms];return min(B)if B else _C
|
|
84
|
+
def ei(D,job):
|
|
85
|
+
A=job
|
|
86
|
+
if not A.enabled:return
|
|
87
|
+
try:
|
|
88
|
+
C=_C
|
|
89
|
+
if A.schedule.kind==_I:from apscheduler.triggers.interval import IntervalTrigger as F;C=F(seconds=A.schedule.every_ms/1000)
|
|
90
|
+
elif A.schedule.kind==_J and A.schedule.expr:
|
|
91
|
+
E=A.schedule.expr.strip();B=E.split()
|
|
92
|
+
if len(B)==6:C=CronTrigger(second=B[0],minute=B[1],hour=B[2],day=B[3],month=B[4],day_of_week=B[5])
|
|
93
|
+
elif len(B)==5:C=CronTrigger.from_crontab(E)
|
|
94
|
+
else:raise ValueError(f"无效的Cron表达式格式: {E} (字段数: {len(B)})")
|
|
95
|
+
elif A.schedule.kind==_G and A.schedule.at_ms:from apscheduler.triggers.date import DateTrigger as G;H=datetime.fromtimestamp(A.schedule.at_ms/1000);C=G(run_date=H)
|
|
96
|
+
else:logger.warning(f"不支持的任务类型: {A.schedule.kind}");return
|
|
97
|
+
D._scheduler.add_job(func=D._execute_job,args=[A],trigger=C,id=A.id,name=A.name,replace_existing=_B,misfire_grace_time=30);logger.debug(f"任务 {A.id} 已添加到调度器,触发器类型: {type(C).__name__}")
|
|
98
|
+
except Exception as I:logger.error(f"添加任务到调度器失败: {I}",exc_info=_B);A.enabled=_A;D.eh()
|
|
99
|
+
async def _execute_job(B,job):
|
|
100
|
+
A=job;D=ed();logger.info(f"Cron: executing job '{A.name}' ({A.id})")
|
|
101
|
+
try:
|
|
102
|
+
E=_C
|
|
103
|
+
if B.on_job:E=await B.on_job(A)
|
|
104
|
+
A.state.last_status='ok';A.state.last_error=_C;logger.info(f"Cron: job '{A.name}' completed")
|
|
105
|
+
except Exception as C:A.state.last_status='error';A.state.last_error=str(C);logger.error(f"Cron: job '{A.name}' failed: {C}")
|
|
106
|
+
A.state.last_run_at_ms=D;A.updated_at_ms=ed()
|
|
107
|
+
if A.schedule.kind==_G:
|
|
108
|
+
if A.delete_after_run:
|
|
109
|
+
B._store.jobs=[B for B in B._store.jobs if B.id!=A.id]
|
|
110
|
+
try:B._scheduler.remove_job(A.id)
|
|
111
|
+
except Exception:pass
|
|
112
|
+
else:
|
|
113
|
+
A.enabled=_A;A.state.next_run_at_ms=_C
|
|
114
|
+
try:B._scheduler.pause_job(A.id)
|
|
115
|
+
except Exception:pass
|
|
116
|
+
else:A.state.next_run_at_ms=eg(A.schedule,ed())
|
|
117
|
+
B.eh()
|
|
118
|
+
def list_jobs(B,include_disabled=_A):A=B.ee();C=A.jobs if include_disabled else[A for A in A.jobs if A.enabled];return sorted(C,key=lambda j:j.state.next_run_at_ms or float('inf'))
|
|
119
|
+
def add_job(A,name,schedule,message,deliver=_A,channel=_C,to=_C,delete_after_run=_A):
|
|
120
|
+
D=schedule;E=A.ee();C=ed();B=CronJob(id=str(uuid.uuid4())[:8],name=name,enabled=_B,schedule=D,payload=CronPayload(kind=_N,message=message,deliver=deliver,channel=channel,to=to),state=CronJobState(next_run_at_ms=eg(D,C)),created_at_ms=C,updated_at_ms=C,delete_after_run=delete_after_run);E.jobs.append(B);A.eh()
|
|
121
|
+
if A._running and A._scheduler.running:A.ei(B)
|
|
122
|
+
logger.info(f"Cron: added job '{name}' ({B.id})");return B
|
|
123
|
+
def remove_job(A,job_id):
|
|
124
|
+
B=job_id;C=A.ee();E=len(C.jobs);C.jobs=[A for A in C.jobs if A.id!=B];D=len(C.jobs)<E
|
|
125
|
+
if D:
|
|
126
|
+
A.eh()
|
|
127
|
+
if A._running and A._scheduler.running:
|
|
128
|
+
try:A._scheduler.remove_job(B)
|
|
129
|
+
except Exception as F:logger.warning(f"从调度器移除任务{B}失败: {F}")
|
|
130
|
+
logger.info(f"Cron: removed job {B}")
|
|
131
|
+
return D
|
|
132
|
+
def enable_job(A,job_id,enabled=_B):
|
|
133
|
+
E=enabled;C=job_id;F=A.ee()
|
|
134
|
+
for B in F.jobs:
|
|
135
|
+
if B.id==C:
|
|
136
|
+
try:D=A._scheduler.get_job(C)
|
|
137
|
+
except Exception:D=_C
|
|
138
|
+
B.enabled=E;B.updated_at_ms=ed()
|
|
139
|
+
if E:
|
|
140
|
+
B.state.next_run_at_ms=eg(B.schedule,ed())
|
|
141
|
+
if A._running and A._scheduler.running:
|
|
142
|
+
if D:A._scheduler.resume_job(C)
|
|
143
|
+
A.ei(B)
|
|
144
|
+
else:
|
|
145
|
+
B.state.next_run_at_ms=_C
|
|
146
|
+
if A._running and A._scheduler.running and D:
|
|
147
|
+
try:A._scheduler.pause_job(C)
|
|
148
|
+
except Exception as G:logger.warning(f"暂停任务{C}失败: {G}")
|
|
149
|
+
A.eh();return B
|
|
150
|
+
async def run_job(A,job_id,force=_A):
|
|
151
|
+
C=A.ee()
|
|
152
|
+
for B in C.jobs:
|
|
153
|
+
if B.id==job_id:
|
|
154
|
+
if not force and not B.enabled:return _A
|
|
155
|
+
await A._execute_job(B);A.eh();return _B
|
|
156
|
+
return _A
|
|
157
|
+
def status(A):B=A.ee();return{_L:A._running,_K:len(B.jobs),'next_wake_at_ms':A.ec()}
|
|
158
|
+
async def main():
|
|
159
|
+
F=Path('./agent_cron.json')
|
|
160
|
+
async def G(job):A=job;logger.info(f"Agent 处理任务: {A.name}");logger.info(f"任务消息: {A.payload.message}");logger.info(f"投递渠道: {A.payload.channel} -> {A.payload.to}");return f"任务 {A.id} 处理完成"
|
|
161
|
+
A=CronService(store_path=F,on_job=G);await A.start();print('\n=== 服务启动状态 ===');print(json.dumps(A.status(),indent=2,ensure_ascii=_A));print('\n=== 添加固定间隔任务 ===');C=A.add_job(name='定时心跳任务',schedule=CronSchedule(kind=_I,every_ms=3000),message='Agent 心跳检测:正常运行中',deliver=_B,channel='agent_internal',to='admin');print(f"添加间隔任务成功,ID: {C.id}");print('\n=== 添加 Cron 表达式任务 ===');D=A.add_job(name='Cron 定时任务',schedule=CronSchedule(kind=_J,expr='*/5 * * * * *'),message='每 5 秒执行一次的 Cron 任务',deliver=_A);print(f"添加 Cron 任务成功,ID: {D.id}");print('\n=== 添加一次性任务 ===');E=ed()+5000;H=A.add_job(name='一次性通知任务',schedule=CronSchedule(kind=_G,at_ms=E),message='5秒后执行的一次性任务,执行后自动删除',deliver=_B,channel='slack',to='user_123',delete_after_run=_B);print(f"添加一次性任务成功,ID: {H.id},执行时间: {datetime.fromtimestamp(E/1000)}");print('\n=== 任务列表 ===');I=A.list_jobs()
|
|
162
|
+
for(J,B)in enumerate(I):print(f"{J+1}. ID: {B.id} | 名称: {B.name} | 类型: {B.schedule.kind} | 状态: {'启用'if B.enabled else'禁用'}")
|
|
163
|
+
print('\n=== 服务状态 ===');K=A.status();print(json.dumps(K,indent=2,ensure_ascii=_A));print('\n=== 手动执行任务 ===');await A.run_job(C.id,force=_B);print('\n=== 运行 20 秒(观察任务执行)===');await asyncio.sleep(20);print('\n=== 禁用 Cron 任务 ===');A.enable_job(D.id,enabled=_A);print(f"任务 {D.id} 已禁用");print('\n=== 删除间隔任务 ===')
|
|
164
|
+
if A.remove_job(C.id):print(f"任务 {C.id} 已删除")
|
|
165
|
+
print('\n=== 最终状态 ===');print(json.dumps(A.status(),indent=2,ensure_ascii=_A));print('\n=== 剩余任务 ===')
|
|
166
|
+
for B in A.list_jobs(include_disabled=_B):print(f"ID: {B.id} | 名称: {B.name} | 状态: {'启用'if B.enabled else'禁用'}")
|
|
167
|
+
print('\n=== 停止 Cron 服务 ===');await A.stop();print('Cron 服务已停止')
|
|
168
|
+
if __name__=='__main__':
|
|
169
|
+
try:asyncio.run(main())
|
|
170
|
+
except KeyboardInterrupt:print('\n程序被手动终止')
|
|
171
|
+
except Exception as e:logger.error(f"程序运行出错: {e}",exc_info=_B);raise
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
_B='agent_turn'
|
|
2
|
+
_A=None
|
|
3
|
+
from dataclasses import dataclass,field
|
|
4
|
+
from typing import Literal
|
|
5
|
+
@dataclass
|
|
6
|
+
class CronSchedule:kind:Literal['at','every','cron'];at_ms:int|_A=_A;every_ms:int|_A=_A;expr:str|_A=_A;tz:str|_A=_A
|
|
7
|
+
@dataclass
|
|
8
|
+
class CronPayload:kind:Literal['system_event',_B]=_B;message:str='';deliver:bool=False;channel:str|_A=_A;to:str|_A=_A
|
|
9
|
+
@dataclass
|
|
10
|
+
class CronJobState:next_run_at_ms:int|_A=_A;last_run_at_ms:int|_A=_A;last_status:Literal['ok','error','skipped']|_A=_A;last_error:str|_A=_A
|
|
11
|
+
@dataclass
|
|
12
|
+
class CronJob:id:str;name:str;enabled:bool=True;schedule:CronSchedule=field(default_factory=lambda:CronSchedule(kind='every'));payload:CronPayload=field(default_factory=CronPayload);state:CronJobState=field(default_factory=CronJobState);created_at_ms:int=0;updated_at_ms:int=0;delete_after_run:bool=False
|
|
13
|
+
@dataclass
|
|
14
|
+
class CronStore:version:int=1;jobs:list[CronJob]=field(default_factory=list)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
_B=False
|
|
2
|
+
_A=True
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any,Callable,Coroutine
|
|
6
|
+
from logging import getLogger
|
|
7
|
+
logger=getLogger(__name__)
|
|
8
|
+
import logging
|
|
9
|
+
logger.setLevel(logging.CRITICAL+1)
|
|
10
|
+
DEFAULT_HEARTBEAT_INTERVAL_S=1800
|
|
11
|
+
HEARTBEAT_PROMPT='Read HEARTBEAT.md in your workspace (if it exists).\nFollow any instructions or tasks listed there.\nIf nothing needs attention, reply with just: HEARTBEAT_OK'
|
|
12
|
+
HEARTBEAT_OK_TOKEN='HEARTBEAT_OK'
|
|
13
|
+
def ea(content):
|
|
14
|
+
B=content
|
|
15
|
+
if not B:return _A
|
|
16
|
+
C={'- [ ]','* [ ]','- [x]','* [x]'}
|
|
17
|
+
for A in B.split('\n'):
|
|
18
|
+
A=A.strip()
|
|
19
|
+
if not A or A.startswith('#')or A.startswith('<!--')or A in C:continue
|
|
20
|
+
return _B
|
|
21
|
+
return _A
|
|
22
|
+
class HeartbeatService:
|
|
23
|
+
def __init__(A,workspace,on_heartbeat=None,interval_s=DEFAULT_HEARTBEAT_INTERVAL_S,enabled=_A):A.workspace=workspace;A.on_heartbeat=on_heartbeat;A.interval_s=interval_s;A.enabled=enabled;A._running=_B;A._task=None
|
|
24
|
+
@property
|
|
25
|
+
def heartbeat_file(self):return self.workspace/'HEARTBEAT.md'
|
|
26
|
+
def eb(A):
|
|
27
|
+
if A.heartbeat_file.exists():
|
|
28
|
+
try:return A.heartbeat_file.read_text()
|
|
29
|
+
except Exception:return
|
|
30
|
+
async def start(A):
|
|
31
|
+
logger.info(f"Starting heartbeat service ...")
|
|
32
|
+
if not A.enabled:logger.info('Heartbeat disabled');return
|
|
33
|
+
A._running=_A;A._task=asyncio.create_task(A._run_loop());logger.info(f"Heartbeat started (every {A.interval_s} seconds)")
|
|
34
|
+
def stop(A):
|
|
35
|
+
A._running=_B
|
|
36
|
+
if A._task:A._task.cancel();A._task=None
|
|
37
|
+
async def _run_loop(A):
|
|
38
|
+
while A._running:
|
|
39
|
+
try:
|
|
40
|
+
await asyncio.sleep(A.interval_s)
|
|
41
|
+
if A._running:await A._tick()
|
|
42
|
+
except asyncio.CancelledError:print('Heartbeat cancelled');break
|
|
43
|
+
except Exception as B:print(f"Heartbeat error: {B}");logger.error(f"Heartbeat error: {B}")
|
|
44
|
+
async def _tick(A):
|
|
45
|
+
B=A.eb()
|
|
46
|
+
if ea(B):logger.info('Heartbeat: no tasks (HEARTBEAT.md empty)');return
|
|
47
|
+
logger.info('Heartbeat: checking for tasks ...')
|
|
48
|
+
if A.on_heartbeat:
|
|
49
|
+
try:
|
|
50
|
+
C=await A.on_heartbeat(HEARTBEAT_PROMPT)
|
|
51
|
+
if HEARTBEAT_OK_TOKEN.replace('_','')in C.upper().replace('_',''):logger.info('Heartbeat: OK (no action needed)')
|
|
52
|
+
else:logger.info('Heartbeat: completed task')
|
|
53
|
+
except Exception as D:logger.error(f"Heartbeat execution failed: {D}")
|
|
54
|
+
async def trigger_now(A):
|
|
55
|
+
if A.on_heartbeat:return await A.on_heartbeat(HEARTBEAT_PROMPT)
|