@myvillage/cli 1.3.0 → 1.5.1
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/README.md +14 -199
- package/package.json +11 -6
- package/src/agent-runtime/context.js +99 -0
- package/src/agent-runtime/daemon-entry.js +66 -0
- package/src/agent-runtime/daemon.js +65 -0
- package/src/agent-runtime/loop.js +281 -0
- package/src/agent-runtime/mcp-client.js +93 -0
- package/src/agent-runtime/scheduler.js +53 -0
- package/src/commands/agent-local.js +624 -0
- package/src/commands/agent.js +274 -42
- package/src/commands/bizreqs.js +965 -0
- package/src/commands/comment.js +5 -4
- package/src/commands/community.js +13 -12
- package/src/commands/create-app.js +253 -0
- package/src/commands/create-game.js +9 -8
- package/src/commands/deploy.js +101 -23
- package/src/commands/feed.js +4 -3
- package/src/commands/login.js +164 -76
- package/src/commands/logout.js +45 -7
- package/src/commands/post.js +14 -13
- package/src/commands/profile.js +4 -3
- package/src/commands/search.js +3 -2
- package/src/commands/soulprint.js +1379 -0
- package/src/commands/status.js +64 -28
- package/src/commands/vote.js +46 -18
- package/src/index.js +244 -1
- package/src/utils/agent-scaffolder.js +165 -0
- package/src/utils/api.js +135 -14
- package/src/utils/app-templates.js +2983 -0
- package/src/utils/brand.js +107 -0
- package/src/utils/config.js +17 -1
- package/src/utils/formatters.js +351 -18
- package/src/utils/local-agent.js +168 -0
- package/src/utils/soulprint-api.js +136 -0
- package/src/utils/soulprint-workspace.js +158 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// ── Agent Loop ──────────────────────────────────────────
|
|
2
|
+
// Core agent loop using Vercel AI SDK. Reads prompt.md,
|
|
3
|
+
// gathers context, calls LLM with tools, logs results.
|
|
4
|
+
|
|
5
|
+
import { generateText } from 'ai';
|
|
6
|
+
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
7
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { getMCPTools, cleanupMCPClients } from './mcp-client.js';
|
|
11
|
+
import { gatherContext } from './context.js';
|
|
12
|
+
import { isWithinActiveHours, getNextCheckInMs } from './scheduler.js';
|
|
13
|
+
import { parse as parseYaml } from 'yaml';
|
|
14
|
+
import { postAgentHeartbeat } from '../utils/api.js';
|
|
15
|
+
|
|
16
|
+
export async function agentLoop(agentName, { signal }) {
|
|
17
|
+
const agentDir = join(homedir(), '.myvillage', 'agents', agentName);
|
|
18
|
+
const configPath = join(agentDir, 'agent.config.yaml');
|
|
19
|
+
const config = parseYaml(readFileSync(configPath, 'utf-8'));
|
|
20
|
+
|
|
21
|
+
// Read API key from global config or environment
|
|
22
|
+
const globalConfigPath = join(homedir(), '.myvillage', 'config.json');
|
|
23
|
+
let apiKey = process.env.ANTHROPIC_API_KEY;
|
|
24
|
+
if (!apiKey && existsSync(globalConfigPath)) {
|
|
25
|
+
try {
|
|
26
|
+
const globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
|
|
27
|
+
apiKey = globalConfig.anthropicApiKey;
|
|
28
|
+
} catch { /* ignore */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!apiKey) {
|
|
32
|
+
logActivity(agentDir, { type: 'error', error: 'No Anthropic API key configured' });
|
|
33
|
+
throw new Error('No Anthropic API key configured');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const anthropic = createAnthropic({ apiKey });
|
|
37
|
+
const modelId = config.brain?.model || 'claude-sonnet-4-5-20250929';
|
|
38
|
+
const model = anthropic(modelId);
|
|
39
|
+
const maxTokens = config.brain?.max_tokens_per_loop || 1000;
|
|
40
|
+
|
|
41
|
+
// Initialize MCP tools
|
|
42
|
+
let tools;
|
|
43
|
+
try {
|
|
44
|
+
tools = await getMCPTools(agentDir, config);
|
|
45
|
+
logActivity(agentDir, {
|
|
46
|
+
type: 'info',
|
|
47
|
+
message: `Agent started. Model: ${modelId}, Tools: ${Object.keys(tools).join(', ') || 'none'}`,
|
|
48
|
+
});
|
|
49
|
+
} catch (err) {
|
|
50
|
+
logActivity(agentDir, { type: 'error', error: `Failed to initialize tools: ${err.message}` });
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let lastCheckIn = null;
|
|
55
|
+
let iteration = 0;
|
|
56
|
+
const recentActions = []; // Track actions across iterations to avoid duplicates
|
|
57
|
+
|
|
58
|
+
while (!signal.aborted) {
|
|
59
|
+
// Check active hours
|
|
60
|
+
if (!isWithinActiveHours(config.schedule)) {
|
|
61
|
+
await sleep(60000, signal);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// For manual-only agents (interval=0), only run once then exit
|
|
66
|
+
const intervalMs = getNextCheckInMs(config.schedule);
|
|
67
|
+
if (intervalMs === 0 && iteration > 0) {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
iteration++;
|
|
72
|
+
const loopStart = Date.now();
|
|
73
|
+
|
|
74
|
+
logActivity(agentDir, { type: 'loop_start', iteration });
|
|
75
|
+
updateHeartbeat(agentDir);
|
|
76
|
+
|
|
77
|
+
// Activity counters for this iteration
|
|
78
|
+
const activity = {
|
|
79
|
+
postsCreated: 0,
|
|
80
|
+
commentsCreated: 0,
|
|
81
|
+
votesGiven: 0,
|
|
82
|
+
toolCalls: 0,
|
|
83
|
+
};
|
|
84
|
+
let feedItemsRead = 0;
|
|
85
|
+
let mentionsFound = 0;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Read prompt.md fresh each iteration (villager may have edited it)
|
|
89
|
+
const promptPath = join(agentDir, 'prompt.md');
|
|
90
|
+
const systemPrompt = existsSync(promptPath)
|
|
91
|
+
? readFileSync(promptPath, 'utf-8')
|
|
92
|
+
: `You are an agent named ${config.display_name || agentName}. Be helpful and concise.`;
|
|
93
|
+
|
|
94
|
+
// Gather context (returns { text, mentionsCount })
|
|
95
|
+
const contextResult = await gatherContext(config, lastCheckIn, recentActions);
|
|
96
|
+
const context = contextResult.text;
|
|
97
|
+
mentionsFound = contextResult.mentionsCount;
|
|
98
|
+
|
|
99
|
+
// Count feed items from context
|
|
100
|
+
feedItemsRead = (context.match(/^- @/gm) || []).length;
|
|
101
|
+
|
|
102
|
+
logActivity(agentDir, {
|
|
103
|
+
type: 'context',
|
|
104
|
+
feedItems: feedItemsRead,
|
|
105
|
+
mentions: mentionsFound,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Call LLM with tools
|
|
109
|
+
const result = await generateText({
|
|
110
|
+
model,
|
|
111
|
+
system: systemPrompt,
|
|
112
|
+
prompt: context,
|
|
113
|
+
tools,
|
|
114
|
+
maxSteps: 5,
|
|
115
|
+
maxTokens,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Log LLM response
|
|
119
|
+
logActivity(agentDir, {
|
|
120
|
+
type: 'llm_response',
|
|
121
|
+
text: (result.text || '').slice(0, 500),
|
|
122
|
+
tokensUsed: {
|
|
123
|
+
prompt: result.usage?.promptTokens || 0,
|
|
124
|
+
completion: result.usage?.completionTokens || 0,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Log tool calls and count activity
|
|
129
|
+
if (result.steps?.length) {
|
|
130
|
+
for (const step of result.steps) {
|
|
131
|
+
if (step.toolCalls?.length) {
|
|
132
|
+
for (const tc of step.toolCalls) {
|
|
133
|
+
activity.toolCalls++;
|
|
134
|
+
if (tc.toolName === 'post_create') activity.postsCreated++;
|
|
135
|
+
if (tc.toolName === 'comment_create') activity.commentsCreated++;
|
|
136
|
+
if (tc.toolName === 'vote_cast') activity.votesGiven++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (step.toolCalls?.length && step.toolResults?.length) {
|
|
140
|
+
for (let i = 0; i < step.toolResults.length; i++) {
|
|
141
|
+
const tr = step.toolResults[i];
|
|
142
|
+
const args = step.toolCalls[i]?.args;
|
|
143
|
+
logActivity(agentDir, {
|
|
144
|
+
type: 'tool_call',
|
|
145
|
+
tool: tr.toolName,
|
|
146
|
+
args,
|
|
147
|
+
result: typeof tr.result === 'string' ? tr.result.slice(0, 200) : 'ok',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
} else if (step.toolResults?.length) {
|
|
151
|
+
for (const tr of step.toolResults) {
|
|
152
|
+
logActivity(agentDir, {
|
|
153
|
+
type: 'tool_call',
|
|
154
|
+
tool: tr.toolName,
|
|
155
|
+
result: typeof tr.result === 'string' ? tr.result.slice(0, 200) : 'ok',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} else if (result.toolCalls?.length) {
|
|
161
|
+
for (const tc of result.toolCalls) {
|
|
162
|
+
activity.toolCalls++;
|
|
163
|
+
if (tc.toolName === 'post_create') activity.postsCreated++;
|
|
164
|
+
if (tc.toolName === 'comment_create') activity.commentsCreated++;
|
|
165
|
+
if (tc.toolName === 'vote_cast') activity.votesGiven++;
|
|
166
|
+
logActivity(agentDir, {
|
|
167
|
+
type: 'tool_call',
|
|
168
|
+
tool: tc.toolName,
|
|
169
|
+
args: tc.args,
|
|
170
|
+
result: 'executed',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Record actions for dedup in future iterations
|
|
176
|
+
const collectActions = (toolName, args) => {
|
|
177
|
+
if (toolName === 'comment_create' && args?.postId) {
|
|
178
|
+
recentActions.push({ type: 'comment', postId: args.postId, ts: new Date().toISOString() });
|
|
179
|
+
} else if (toolName === 'post_create' && args?.communitySlug) {
|
|
180
|
+
recentActions.push({ type: 'post', community: args.communitySlug, ts: new Date().toISOString() });
|
|
181
|
+
} else if (toolName === 'vote_cast' && args?.targetId) {
|
|
182
|
+
recentActions.push({ type: 'vote', targetId: args.targetId, targetType: args.targetType, ts: new Date().toISOString() });
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
if (result.steps?.length) {
|
|
186
|
+
for (const step of result.steps) {
|
|
187
|
+
for (const tc of step.toolCalls || []) {
|
|
188
|
+
collectActions(tc.toolName, tc.args);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} else if (result.toolCalls?.length) {
|
|
192
|
+
for (const tc of result.toolCalls) {
|
|
193
|
+
collectActions(tc.toolName, tc.args);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Keep only last 50 actions to bound memory
|
|
197
|
+
if (recentActions.length > 50) recentActions.splice(0, recentActions.length - 50);
|
|
198
|
+
|
|
199
|
+
// Send server-side heartbeat
|
|
200
|
+
if (config.man?.agent_id) {
|
|
201
|
+
try {
|
|
202
|
+
await postAgentHeartbeat(config.man.agent_id, {
|
|
203
|
+
postsCreated: activity.postsCreated,
|
|
204
|
+
commentsCreated: activity.commentsCreated,
|
|
205
|
+
votesGiven: activity.votesGiven,
|
|
206
|
+
toolCalls: activity.toolCalls,
|
|
207
|
+
modelUsed: modelId,
|
|
208
|
+
tokensUsed: (result.usage?.promptTokens || 0) + (result.usage?.completionTokens || 0),
|
|
209
|
+
durationMs: Date.now() - loopStart,
|
|
210
|
+
activitySummary: { feedItemsRead, mentionsFound },
|
|
211
|
+
});
|
|
212
|
+
} catch {
|
|
213
|
+
logActivity(agentDir, { type: 'error', error: 'Failed to send server heartbeat' });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
logActivity(agentDir, {
|
|
218
|
+
type: 'error',
|
|
219
|
+
error: err.message,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
lastCheckIn = new Date().toISOString();
|
|
224
|
+
const duration = Date.now() - loopStart;
|
|
225
|
+
|
|
226
|
+
logActivity(agentDir, { type: 'loop_end', iteration, duration_ms: duration });
|
|
227
|
+
updateHeartbeat(agentDir);
|
|
228
|
+
|
|
229
|
+
// Sleep until next interval
|
|
230
|
+
if (intervalMs > 0 && !signal.aborted) {
|
|
231
|
+
await sleep(intervalMs, signal);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Cleanup
|
|
236
|
+
await cleanupMCPClients();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Helpers ─────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
function logActivity(agentDir, entry) {
|
|
242
|
+
const logsDir = join(agentDir, 'logs');
|
|
243
|
+
if (!existsSync(logsDir)) {
|
|
244
|
+
mkdirSync(logsDir, { recursive: true });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
248
|
+
const logFile = join(logsDir, `${today}.jsonl`);
|
|
249
|
+
|
|
250
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
|
|
251
|
+
appendFileSync(logFile, line);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function updateHeartbeat(agentDir) {
|
|
255
|
+
const pidFile = join(agentDir, 'daemon.pid');
|
|
256
|
+
if (!existsSync(pidFile)) return;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const info = JSON.parse(readFileSync(pidFile, 'utf-8'));
|
|
260
|
+
info.lastHeartbeat = new Date().toISOString();
|
|
261
|
+
writeFileSync(pidFile, JSON.stringify(info));
|
|
262
|
+
} catch {
|
|
263
|
+
// PID file may be momentarily unreadable, skip
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sleep(ms, signal) {
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
if (signal?.aborted) { resolve(); return; }
|
|
270
|
+
|
|
271
|
+
const timer = setTimeout(resolve, ms);
|
|
272
|
+
|
|
273
|
+
if (signal) {
|
|
274
|
+
const onAbort = () => {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
resolve();
|
|
277
|
+
};
|
|
278
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// ── MCP Client Manager ──────────────────────────────────
|
|
2
|
+
// Reads tools.yaml, spawns MCP servers, and exposes tools
|
|
3
|
+
// to the Vercel AI SDK for use in the agent loop.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { parse as parseYaml } from 'yaml';
|
|
8
|
+
|
|
9
|
+
const activeClients = [];
|
|
10
|
+
|
|
11
|
+
// ── Public API ──────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export async function getMCPTools(agentDir, agentConfig) {
|
|
14
|
+
const toolsPath = join(agentDir, 'tools.yaml');
|
|
15
|
+
let toolsConfig;
|
|
16
|
+
try {
|
|
17
|
+
toolsConfig = parseYaml(readFileSync(toolsPath, 'utf-8'));
|
|
18
|
+
} catch {
|
|
19
|
+
toolsConfig = { servers: {} };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const allTools = {};
|
|
23
|
+
|
|
24
|
+
for (const [name, server] of Object.entries(toolsConfig.servers || {})) {
|
|
25
|
+
if (server.command === 'internal') {
|
|
26
|
+
// Legacy internal tools — skip (replaced by MCP server)
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const { experimental_createMCPClient: createMCPClient } = await import('ai');
|
|
32
|
+
let client;
|
|
33
|
+
|
|
34
|
+
if (server.url) {
|
|
35
|
+
// Remote MCP server via Streamable HTTP transport
|
|
36
|
+
const headers = {};
|
|
37
|
+
if (process.env.MYVILLAGE_ACCESS_TOKEN) {
|
|
38
|
+
headers['Authorization'] = `Bearer ${process.env.MYVILLAGE_ACCESS_TOKEN}`;
|
|
39
|
+
}
|
|
40
|
+
if (process.env.MYVILLAGE_AGENT_ID) {
|
|
41
|
+
headers['X-Agent-Id'] = process.env.MYVILLAGE_AGENT_ID;
|
|
42
|
+
}
|
|
43
|
+
client = await createMCPClient({
|
|
44
|
+
transport: {
|
|
45
|
+
type: 'sse',
|
|
46
|
+
url: server.url,
|
|
47
|
+
headers,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
} else {
|
|
51
|
+
// Local MCP servers via stdio transport
|
|
52
|
+
client = await createMCPClient({
|
|
53
|
+
transport: {
|
|
54
|
+
type: 'stdio',
|
|
55
|
+
command: server.command,
|
|
56
|
+
args: server.args || [],
|
|
57
|
+
env: { ...process.env, ...resolveEnvVars(server.env || {}) },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
activeClients.push(client);
|
|
63
|
+
const tools = await client.tools();
|
|
64
|
+
Object.assign(allTools, tools);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// Log but don't fail — agent can still work with other tools
|
|
67
|
+
console.error(`[mcp-client] Failed to connect to ${name}: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return allTools;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function cleanupMCPClients() {
|
|
75
|
+
for (const client of activeClients) {
|
|
76
|
+
try { await client.close(); } catch { /* ignore */ }
|
|
77
|
+
}
|
|
78
|
+
activeClients.length = 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Helpers ─────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function resolveEnvVars(envMap) {
|
|
84
|
+
const resolved = {};
|
|
85
|
+
for (const [key, val] of Object.entries(envMap)) {
|
|
86
|
+
if (typeof val === 'string') {
|
|
87
|
+
resolved[key] = val.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] || '');
|
|
88
|
+
} else {
|
|
89
|
+
resolved[key] = val;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return resolved;
|
|
93
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ── Scheduler ───────────────────────────────────────────
|
|
2
|
+
// Active hours checking and interval timing for agent loops.
|
|
3
|
+
|
|
4
|
+
export function isWithinActiveHours(schedule) {
|
|
5
|
+
if (!schedule?.active_hours?.start || !schedule?.active_hours?.end) {
|
|
6
|
+
return true; // No active hours configured = always active
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const now = new Date();
|
|
10
|
+
const tz = schedule.timezone || undefined;
|
|
11
|
+
|
|
12
|
+
// Get current hours/minutes in the configured timezone
|
|
13
|
+
let currentHours, currentMinutes;
|
|
14
|
+
if (tz) {
|
|
15
|
+
try {
|
|
16
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
17
|
+
timeZone: tz,
|
|
18
|
+
hour: 'numeric',
|
|
19
|
+
minute: 'numeric',
|
|
20
|
+
hour12: false,
|
|
21
|
+
});
|
|
22
|
+
const parts = formatter.formatToParts(now);
|
|
23
|
+
currentHours = parseInt(parts.find(p => p.type === 'hour')?.value || '0');
|
|
24
|
+
currentMinutes = parseInt(parts.find(p => p.type === 'minute')?.value || '0');
|
|
25
|
+
} catch {
|
|
26
|
+
// Fallback to local time if timezone is invalid
|
|
27
|
+
currentHours = now.getHours();
|
|
28
|
+
currentMinutes = now.getMinutes();
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
currentHours = now.getHours();
|
|
32
|
+
currentMinutes = now.getMinutes();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const currentTime = currentHours * 60 + currentMinutes;
|
|
36
|
+
|
|
37
|
+
const [startH, startM] = schedule.active_hours.start.split(':').map(Number);
|
|
38
|
+
const [endH, endM] = schedule.active_hours.end.split(':').map(Number);
|
|
39
|
+
const startTime = startH * 60 + (startM || 0);
|
|
40
|
+
const endTime = endH * 60 + (endM || 0);
|
|
41
|
+
|
|
42
|
+
if (startTime <= endTime) {
|
|
43
|
+
return currentTime >= startTime && currentTime <= endTime;
|
|
44
|
+
}
|
|
45
|
+
// Handles overnight ranges (e.g., 22:00 - 06:00)
|
|
46
|
+
return currentTime >= startTime || currentTime <= endTime;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getNextCheckInMs(schedule) {
|
|
50
|
+
const interval = schedule?.check_in_interval;
|
|
51
|
+
if (!interval || interval <= 0) return 0; // manual only
|
|
52
|
+
return interval * 60 * 1000;
|
|
53
|
+
}
|