ticlawk 0.1.16-dev.1 → 0.1.16-dev.10

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/src/core/http.mjs CHANGED
@@ -1,11 +1,30 @@
1
1
  import { createServer } from 'node:http';
2
2
  import {
3
+ handleAttachmentUpload,
4
+ handleAttachmentView,
5
+ handleGroupCreate,
3
6
  handleGroupMembers,
7
+ handleGroupMembersAdd,
8
+ handleGroupMembersRemove,
9
+ handleMessageCheck,
10
+ handleMessageReact,
4
11
  handleMessageRead,
12
+ handleMessageSearch,
5
13
  handleMessageSend,
14
+ handleProfileAvatarUpload,
15
+ handleProfileShow,
16
+ handleProfileUpdate,
17
+ handleReminderCancel,
18
+ handleReminderList,
19
+ handleReminderLog,
20
+ handleReminderSchedule,
21
+ handleReminderSnooze,
22
+ handleReminderUpdate,
6
23
  handleServerInfo,
7
24
  handleTaskClaim,
25
+ handleTaskCreate,
8
26
  handleTaskList,
27
+ handleTaskUnclaim,
9
28
  handleTaskUpdate,
10
29
  } from './agent-cli-handlers.mjs';
11
30
 
@@ -78,12 +97,24 @@ export function startLocalHttpServer({ port, adapter, ctx }) {
78
97
  const r = await handleMessageRead(req, parseQuery(req.url || ''), cliCtx);
79
98
  return writeJson(res, r.status, r.body);
80
99
  }
100
+ if (urlNoQuery === '/agent/task/create' && method === 'POST') {
101
+ const body = await readJsonBody(req);
102
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
103
+ const r = await handleTaskCreate(req, body, cliCtx);
104
+ return writeJson(res, r.status, r.body);
105
+ }
81
106
  if (urlNoQuery === '/agent/task/claim' && method === 'POST') {
82
107
  const body = await readJsonBody(req);
83
108
  if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
84
109
  const r = await handleTaskClaim(req, body, cliCtx);
85
110
  return writeJson(res, r.status, r.body);
86
111
  }
112
+ if (urlNoQuery === '/agent/task/unclaim' && method === 'POST') {
113
+ const body = await readJsonBody(req);
114
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
115
+ const r = await handleTaskUnclaim(req, body, cliCtx);
116
+ return writeJson(res, r.status, r.body);
117
+ }
87
118
  if (urlNoQuery === '/agent/task/update' && method === 'POST') {
88
119
  const body = await readJsonBody(req);
89
120
  if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
@@ -94,10 +125,100 @@ export function startLocalHttpServer({ port, adapter, ctx }) {
94
125
  const r = await handleTaskList(req, parseQuery(req.url || ''), cliCtx);
95
126
  return writeJson(res, r.status, r.body);
96
127
  }
128
+ if (urlNoQuery === '/agent/message/react' && method === 'POST') {
129
+ const body = await readJsonBody(req);
130
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
131
+ const r = await handleMessageReact(req, body, cliCtx);
132
+ return writeJson(res, r.status, r.body);
133
+ }
134
+ if (urlNoQuery === '/agent/message/check' && method === 'GET') {
135
+ const r = await handleMessageCheck(req, parseQuery(req.url || ''), cliCtx);
136
+ return writeJson(res, r.status, r.body);
137
+ }
138
+ if (urlNoQuery === '/agent/message/search' && method === 'GET') {
139
+ const r = await handleMessageSearch(req, parseQuery(req.url || ''), cliCtx);
140
+ return writeJson(res, r.status, r.body);
141
+ }
142
+ if (urlNoQuery === '/agent/profile/show' && method === 'GET') {
143
+ const r = await handleProfileShow(req, parseQuery(req.url || ''), cliCtx);
144
+ return writeJson(res, r.status, r.body);
145
+ }
146
+ if (urlNoQuery === '/agent/profile/update' && method === 'POST') {
147
+ const body = await readJsonBody(req);
148
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
149
+ const r = await handleProfileUpdate(req, body, cliCtx);
150
+ return writeJson(res, r.status, r.body);
151
+ }
152
+ if (urlNoQuery === '/agent/profile/avatar' && method === 'POST') {
153
+ const body = await readJsonBody(req);
154
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
155
+ const r = await handleProfileAvatarUpload(req, body, cliCtx);
156
+ return writeJson(res, r.status, r.body);
157
+ }
158
+ if (urlNoQuery === '/agent/attachment/upload' && method === 'POST') {
159
+ const body = await readJsonBody(req);
160
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
161
+ const r = await handleAttachmentUpload(req, body, cliCtx);
162
+ return writeJson(res, r.status, r.body);
163
+ }
164
+ if (urlNoQuery === '/agent/attachment/view' && method === 'GET') {
165
+ const r = await handleAttachmentView(req, parseQuery(req.url || ''), cliCtx);
166
+ return writeJson(res, r.status, r.body);
167
+ }
168
+ if (urlNoQuery === '/agent/reminder/schedule' && method === 'POST') {
169
+ const body = await readJsonBody(req);
170
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
171
+ const r = await handleReminderSchedule(req, body, cliCtx);
172
+ return writeJson(res, r.status, r.body);
173
+ }
174
+ if (urlNoQuery === '/agent/reminder/list' && method === 'GET') {
175
+ const r = await handleReminderList(req, parseQuery(req.url || ''), cliCtx);
176
+ return writeJson(res, r.status, r.body);
177
+ }
178
+ if (urlNoQuery === '/agent/reminder/snooze' && method === 'POST') {
179
+ const body = await readJsonBody(req);
180
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
181
+ const r = await handleReminderSnooze(req, body, cliCtx);
182
+ return writeJson(res, r.status, r.body);
183
+ }
184
+ if (urlNoQuery === '/agent/reminder/update' && method === 'POST') {
185
+ const body = await readJsonBody(req);
186
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
187
+ const r = await handleReminderUpdate(req, body, cliCtx);
188
+ return writeJson(res, r.status, r.body);
189
+ }
190
+ if (urlNoQuery === '/agent/reminder/cancel' && method === 'POST') {
191
+ const body = await readJsonBody(req);
192
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
193
+ const r = await handleReminderCancel(req, body, cliCtx);
194
+ return writeJson(res, r.status, r.body);
195
+ }
196
+ if (urlNoQuery === '/agent/reminder/log' && method === 'GET') {
197
+ const r = await handleReminderLog(req, parseQuery(req.url || ''), cliCtx);
198
+ return writeJson(res, r.status, r.body);
199
+ }
97
200
  if (urlNoQuery === '/agent/group/members' && method === 'GET') {
98
201
  const r = await handleGroupMembers(req, parseQuery(req.url || ''), cliCtx);
99
202
  return writeJson(res, r.status, r.body);
100
203
  }
204
+ if (urlNoQuery === '/agent/group/create' && method === 'POST') {
205
+ const body = await readJsonBody(req);
206
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
207
+ const r = await handleGroupCreate(req, body, cliCtx);
208
+ return writeJson(res, r.status, r.body);
209
+ }
210
+ if (urlNoQuery === '/agent/group/members/add' && method === 'POST') {
211
+ const body = await readJsonBody(req);
212
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
213
+ const r = await handleGroupMembersAdd(req, body, cliCtx);
214
+ return writeJson(res, r.status, r.body);
215
+ }
216
+ if (urlNoQuery === '/agent/group/members/remove' && method === 'POST') {
217
+ const body = await readJsonBody(req);
218
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
219
+ const r = await handleGroupMembersRemove(req, body, cliCtx);
220
+ return writeJson(res, r.status, r.body);
221
+ }
101
222
  if (urlNoQuery === '/agent/server/info' && method === 'GET') {
102
223
  const r = await handleServerInfo(req, parseQuery(req.url || ''), cliCtx);
103
224
  return writeJson(res, r.status, r.body);
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Daemon-side reminder ticker.
3
+ *
4
+ * Runs every 60s. POSTs /api/agent/reminders/fire-due via the existing
5
+ * service-role connector path. The edge function calls the
6
+ * fire_due_reminders SQL function which atomically claims due
7
+ * reminders, posts a system message into each anchor conversation, and
8
+ * inserts the explicit message_delivery row to the owner agent.
9
+ *
10
+ * Failures are logged at boundaries and do not retry — the next tick
11
+ * picks them up. This is the only writer of "reminder fired" state, so
12
+ * there is no double-fire risk even across multiple daemon restarts
13
+ * (FOR UPDATE SKIP LOCKED inside the SQL function serialises).
14
+ *
15
+ * Disabled by setting TICLAWK_DISABLE_REMINDER_TICKER=1 in the
16
+ * environment (useful if you want to drive firing from elsewhere).
17
+ */
18
+
19
+ import { fireDueReminders } from '../adapters/ticlawk/api.mjs';
20
+ import { debugError, debugLog } from './logger.mjs';
21
+
22
+ const DEFAULT_INTERVAL_MS = 60 * 1000;
23
+
24
+ export function startReminderTicker({
25
+ intervalMs = DEFAULT_INTERVAL_MS,
26
+ } = {}) {
27
+ if (String(process.env.TICLAWK_DISABLE_REMINDER_TICKER || '').trim() === '1') {
28
+ debugLog('reminder-ticker', 'disabled', { reason: 'TICLAWK_DISABLE_REMINDER_TICKER=1' });
29
+ return { stop: () => {} };
30
+ }
31
+
32
+ let stopped = false;
33
+ let timer = null;
34
+
35
+ async function tick() {
36
+ if (stopped) return;
37
+ try {
38
+ const res = await fireDueReminders();
39
+ const count = res?.fired_count || 0;
40
+ if (count > 0) {
41
+ debugLog('reminder-ticker', 'fired', {
42
+ count,
43
+ ids: (res?.fired || []).map((r) => r?.reminder_id),
44
+ });
45
+ }
46
+ } catch (err) {
47
+ debugError('reminder-ticker', 'tick.failed', {
48
+ error: err?.message || String(err),
49
+ status: err?.status || null,
50
+ });
51
+ } finally {
52
+ if (!stopped) timer = setTimeout(tick, intervalMs);
53
+ }
54
+ }
55
+
56
+ debugLog('reminder-ticker', 'start', { intervalMs });
57
+ // Start with an initial small delay so the daemon's HTTP server is
58
+ // up before the first tick.
59
+ timer = setTimeout(tick, 5000);
60
+
61
+ return {
62
+ stop() {
63
+ stopped = true;
64
+ if (timer) {
65
+ clearTimeout(timer);
66
+ timer = null;
67
+ }
68
+ },
69
+ };
70
+ }
@@ -42,7 +42,7 @@ import { normalizeServiceType } from './runtime-registry.mjs';
42
42
  * @property {(bindingId: string) => any} getBinding
43
43
  * @property {(filters?: Record<string, any>) => any[]} listBindings
44
44
  * @property {(bindingId: string) => Promise<void>} [deleteBinding]
45
- * @property {(binding: any) => Promise<any>} [cacheBinding]
45
+ * @property {(binding: any) => Promise<any>} [persistBinding]
46
46
  * @property {(binding: any) => Promise<any>} upsertBinding
47
47
  * @property {(inbound: any, runtimeName: string) => Promise<string>} buildImageMessageFromInbound
48
48
  * @property {any} logger
@@ -1,4 +1,5 @@
1
1
  import { getStreamingMode } from './config.mjs';
2
+ import { getAgentHome } from './agent-home.mjs';
2
3
  const ERROR_MAX_CHARS = 500;
3
4
  const DEFAULT_DELTA_FLUSH_MS = 250;
4
5
  const DEFAULT_DELTA_FLUSH_CHARS = 64;
@@ -15,7 +16,7 @@ function runtimeContextLines(binding, info = {}) {
15
16
  const lines = [];
16
17
  if (meta.sessionId) lines.push(`Session: ${meta.sessionId}`);
17
18
  if (info.turnId) lines.push(`Turn: ${info.turnId}`);
18
- if (meta.cwd || meta.workdir) lines.push(`Workdir: ${meta.cwd || meta.workdir}`);
19
+ if (binding?.id) lines.push(`Workdir: ${getAgentHome(binding.id)}`);
19
20
  return lines;
20
21
  }
21
22
 
@@ -41,72 +42,43 @@ export function shouldStreamRuntime(runtimeName, runtime) {
41
42
  return Boolean(runtime?.runTurnStream) && getStreamingMode(runtimeName);
42
43
  }
43
44
 
44
- export async function sendAdapterMessage(adapter, binding, payload) {
45
- await adapter.send(binding, payload);
46
- }
47
-
48
- /**
49
- * Record the runtime's final turn output as activity (NOT chat).
50
- *
51
- * Previously called `sendResult` and treated as "the chat reply path".
52
- * After the group-chat upgrade, chat is produced exclusively by the
53
- * agent invoking `ticlawk message send` via the CLI. The runtime's
54
- * raw final output is still surfaced for trajectory/debug UI, but it
55
- * no longer materializes as a `messages` row — the trigger
56
- * `project_agent_event` was updated in PR-2b to drop the chat
57
- * projection.
58
- *
59
- * Renamed so the call sites read self-evidently: "record activity"
60
- * never reads as "send a chat message".
61
- */
62
- export async function recordActivity(adapter, binding, inbound, result) {
63
- if (!result?.text && (!result?.mediaUrls || result.mediaUrls.length === 0)) return;
64
- await sendAdapterMessage(adapter, binding, {
65
- type: 'assistant',
66
- text: result.text || '',
67
- media: result.media || [],
68
- turnId: inbound.messageId || result?.turnId || null,
69
- replyToMessageId: inbound.messageId || null,
70
- });
71
- }
72
-
73
-
74
45
  export async function reportSubprocessFailure({ adapter, binding, inbound, runtimeName, info }) {
75
46
  if (!info || info.ok) return;
76
47
  const seconds = ((info.durationMs || 0) / 1000).toFixed(1);
77
- if (runtimeName === 'Codex' && isRuntimeGatewayFailure(info)) {
78
- await sendAdapterMessage(adapter, binding, {
79
- type: 'assistant',
80
- text: buildCodexGatewayFailureText({ binding, info, seconds }),
81
- media: [],
82
- turnId: inbound?.messageId || null,
83
- replyToMessageId: inbound?.messageId || null,
84
- });
85
- return;
86
- }
87
48
 
88
- let summary;
89
- if (info.kind === 'killed') {
90
- summary = `⚠️ ${runtimeName} was killed by signal ${info.signal} after ${seconds}s.`;
91
- } else if (info.kind === 'spawn-failed') {
92
- summary = `⚠️ ${runtimeName} failed to spawn: ${info.error || 'unknown error'}.`;
93
- } else if (info.kind === 'gateway-timeout') {
94
- summary = `⚠️ ${runtimeName} did not respond after ${seconds}s (timeout).`;
95
- } else if (info.kind === 'gateway-error') {
96
- summary = `⚠️ ${runtimeName} returned an error after ${seconds}s.`;
97
- } else if (typeof info.code === 'number') {
98
- summary = `⚠️ ${runtimeName} exited with code ${info.code} after ${seconds}s.`;
49
+ let text;
50
+ if (runtimeName === 'Codex' && isRuntimeGatewayFailure(info)) {
51
+ text = buildCodexGatewayFailureText({ binding, info, seconds });
99
52
  } else {
100
- summary = `⚠️ ${runtimeName} failed after ${seconds}s.`;
53
+ let summary;
54
+ if (info.kind === 'killed') {
55
+ summary = `⚠️ ${runtimeName} was killed by signal ${info.signal} after ${seconds}s.`;
56
+ } else if (info.kind === 'spawn-failed') {
57
+ summary = `⚠️ ${runtimeName} failed to spawn: ${info.error || 'unknown error'}.`;
58
+ } else if (info.kind === 'gateway-timeout') {
59
+ summary = `⚠️ ${runtimeName} did not respond after ${seconds}s (timeout).`;
60
+ } else if (info.kind === 'gateway-error') {
61
+ summary = `⚠️ ${runtimeName} returned an error after ${seconds}s.`;
62
+ } else if (typeof info.code === 'number') {
63
+ summary = `⚠️ ${runtimeName} exited with code ${info.code} after ${seconds}s.`;
64
+ } else {
65
+ summary = `⚠️ ${runtimeName} failed after ${seconds}s.`;
66
+ }
67
+ const detail = truncateError(info.errorMessage);
68
+ text = detail ? `${summary}\n\n${detail}` : `${summary}\n\nCheck ~/.ticlawk/ticlawk.log on the host for details.`;
101
69
  }
102
- const detail = truncateError(info.errorMessage);
103
- const text = detail ? `${summary}\n\n${detail}` : `${summary}\n\nCheck ~/.ticlawk/ticlawk.log on the host for details.`;
104
- await sendAdapterMessage(adapter, binding, {
105
- type: 'assistant',
70
+
71
+ // Runtime never got to invoke `ticlawk message send`, so the daemon
72
+ // speaks for the agent. visibility='admin' so the message reaches
73
+ // owner/admin members only — the enqueue_message_deliveries trigger
74
+ // skips fan-out to member-role agents, breaking what would otherwise
75
+ // be a failure→fan-out→failure cascade when several agents share a
76
+ // broken runtime.
77
+ await adapter.postAgentReply(binding, {
78
+ conversationId: inbound?.conversationId || null,
106
79
  text,
107
- media: [],
108
- turnId: inbound?.messageId || null,
109
80
  replyToMessageId: inbound?.messageId || null,
81
+ visibility: 'admin',
110
82
  });
111
83
  }
112
84
 
@@ -30,7 +30,7 @@ function createUpsertBindingWithSync(runtimes, adapter) {
30
30
  };
31
31
  }
32
32
 
33
- function createCacheBinding(runtimes, getAdapter) {
33
+ function createPersistBinding(runtimes, getAdapter) {
34
34
  return async (binding) => {
35
35
  const nextBinding = await upsertBinding(binding);
36
36
  const runtime = nextBinding?.runtime ? runtimes[nextBinding.runtime] : null;
@@ -48,7 +48,7 @@ export async function createTiclawkController(adapterId = ADAPTER_ID) {
48
48
  const { runtimes } = await buildRuntimeContext();
49
49
  const resolveRuntimeBinding = createResolveRuntimeBinding(runtimes);
50
50
  let adapter;
51
- const cacheBinding = createCacheBinding(runtimes, () => adapter);
51
+ const persistBinding = createPersistBinding(runtimes, () => adapter);
52
52
  let syncBinding = async (binding) => {
53
53
  if (!adapter) {
54
54
  throw new Error('adapter not initialized');
@@ -64,7 +64,7 @@ export async function createTiclawkController(adapterId = ADAPTER_ID) {
64
64
  deleteBinding,
65
65
  findBindingByTarget,
66
66
  resolveRuntimeBinding,
67
- cacheBinding: (binding) => cacheBinding(binding),
67
+ persistBinding: (binding) => persistBinding(binding),
68
68
  upsertBinding: (binding) => syncBinding(binding),
69
69
  buildImageMessageFromInbound,
70
70
  logger,
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // Seed the per-agent home + MEMORY.md for every paired agent in the
3
+ // linked Supabase project.
4
+ //
5
+ // ~/.ticlawk/agents/<agent_id>/MEMORY.md
6
+ //
7
+ // Replaces the Phase-B variant that wrote MEMORY.md into each agent's
8
+ // project workdir. Each agent now has its own home dir as cwd, with
9
+ // MEMORY.md living at the root of that home.
10
+ //
11
+ // Usage:
12
+ // node src/migrate/write-initial-memory.mjs # dry-run
13
+ // node src/migrate/write-initial-memory.mjs --apply # actually write
14
+ //
15
+ // Idempotent: existing MEMORY.md files are never overwritten.
16
+
17
+ import fs from 'node:fs';
18
+ import { ensureAgentHome, getAgentHome, getAgentMemoryPath } from '../core/agent-home.mjs';
19
+
20
+ const APPLY = process.argv.includes('--apply');
21
+
22
+ function loadEnv(file) {
23
+ const text = fs.readFileSync(file, 'utf8');
24
+ const out = {};
25
+ for (const line of text.split('\n')) {
26
+ const trimmed = line.trim();
27
+ if (!trimmed || trimmed.startsWith('#')) continue;
28
+ const eq = trimmed.indexOf('=');
29
+ if (eq < 0) continue;
30
+ const k = trimmed.slice(0, eq).trim();
31
+ let v = trimmed.slice(eq + 1).trim();
32
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
33
+ v = v.slice(1, -1);
34
+ }
35
+ out[k] = v;
36
+ }
37
+ return out;
38
+ }
39
+
40
+ const ENV_PATH = '/home/wei/Projects/ticlawk/.env.local';
41
+ const env = loadEnv(ENV_PATH);
42
+ const SUPABASE_URL = env.SUPABASE_URL;
43
+ const SUPABASE_SECRET_KEY = env.SUPABASE_SECRET_KEY;
44
+ if (!SUPABASE_URL || !SUPABASE_SECRET_KEY) {
45
+ console.error('Missing SUPABASE_URL or SUPABASE_SECRET_KEY in', ENV_PATH);
46
+ process.exit(2);
47
+ }
48
+
49
+ async function rest(pathRel, init = {}) {
50
+ const res = await fetch(`${SUPABASE_URL}/rest/v1/${pathRel}`, {
51
+ ...init,
52
+ headers: {
53
+ apikey: SUPABASE_SECRET_KEY,
54
+ Authorization: `Bearer ${SUPABASE_SECRET_KEY}`,
55
+ 'Content-Type': 'application/json',
56
+ ...(init.headers || {}),
57
+ },
58
+ });
59
+ if (!res.ok) {
60
+ const body = await res.text();
61
+ throw new Error(`REST ${pathRel} → ${res.status}: ${body}`);
62
+ }
63
+ return res.json();
64
+ }
65
+
66
+ async function main() {
67
+ const agents = await rest('agents?select=id,name,display_name,service_type,meta');
68
+ const plan = agents.map((agent) => ({
69
+ agent,
70
+ home: getAgentHome(agent.id),
71
+ memoryPath: getAgentMemoryPath(agent.id),
72
+ already: fs.existsSync(getAgentMemoryPath(agent.id)),
73
+ }));
74
+
75
+ console.log(`[memory-migrate] ${plan.length} agents; mode=${APPLY ? 'apply' : 'dry-run'}`);
76
+ for (const p of plan) {
77
+ const tag = p.already ? 'SKIP ' : 'WRITE';
78
+ const note = p.already ? 'MEMORY.md already present' : `→ ${p.memoryPath}`;
79
+ console.log(`[memory-migrate] ${tag} ${p.agent.name || p.agent.id}: ${note}`);
80
+ }
81
+
82
+ if (!APPLY) {
83
+ console.log('[memory-migrate] dry-run done. Re-run with --apply to write files.');
84
+ return;
85
+ }
86
+
87
+ let written = 0;
88
+ for (const p of plan) {
89
+ if (p.already) continue;
90
+ ensureAgentHome(p.agent.id, {
91
+ displayName: p.agent.display_name || p.agent.name || null,
92
+ });
93
+ written += 1;
94
+ }
95
+ console.log(`[memory-migrate] wrote ${written} MEMORY.md files (skipped ${plan.length - written}).`);
96
+ }
97
+
98
+ main().catch((err) => {
99
+ console.error('[memory-migrate] failed:', err.message);
100
+ process.exit(1);
101
+ });