ticlawk 0.1.16-dev.1 → 0.1.16-dev.3

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.
@@ -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
 
@@ -74,39 +75,40 @@ export async function recordActivity(adapter, binding, inbound, result) {
74
75
  export async function reportSubprocessFailure({ adapter, binding, inbound, runtimeName, info }) {
75
76
  if (!info || info.ok) return;
76
77
  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
78
 
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.`;
79
+ let text;
80
+ if (runtimeName === 'Codex' && isRuntimeGatewayFailure(info)) {
81
+ text = buildCodexGatewayFailureText({ binding, info, seconds });
99
82
  } else {
100
- summary = `⚠️ ${runtimeName} failed after ${seconds}s.`;
83
+ let summary;
84
+ if (info.kind === 'killed') {
85
+ summary = `⚠️ ${runtimeName} was killed by signal ${info.signal} after ${seconds}s.`;
86
+ } else if (info.kind === 'spawn-failed') {
87
+ summary = `⚠️ ${runtimeName} failed to spawn: ${info.error || 'unknown error'}.`;
88
+ } else if (info.kind === 'gateway-timeout') {
89
+ summary = `⚠️ ${runtimeName} did not respond after ${seconds}s (timeout).`;
90
+ } else if (info.kind === 'gateway-error') {
91
+ summary = `⚠️ ${runtimeName} returned an error after ${seconds}s.`;
92
+ } else if (typeof info.code === 'number') {
93
+ summary = `⚠️ ${runtimeName} exited with code ${info.code} after ${seconds}s.`;
94
+ } else {
95
+ summary = `⚠️ ${runtimeName} failed after ${seconds}s.`;
96
+ }
97
+ const detail = truncateError(info.errorMessage);
98
+ text = detail ? `${summary}\n\n${detail}` : `${summary}\n\nCheck ~/.ticlawk/ticlawk.log on the host for details.`;
101
99
  }
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',
100
+
101
+ // Runtime never got to invoke `ticlawk message send`, so the daemon
102
+ // speaks for the agent. visibility='admin' so the message reaches
103
+ // owner/admin members only — the enqueue_message_deliveries trigger
104
+ // skips fan-out to member-role agents, breaking what would otherwise
105
+ // be a failure→fan-out→failure cascade when several agents share a
106
+ // broken runtime.
107
+ await adapter.postAgentReply(binding, {
108
+ conversationId: inbound?.conversationId || null,
106
109
  text,
107
- media: [],
108
- turnId: inbound?.messageId || null,
109
110
  replyToMessageId: inbound?.messageId || null,
111
+ visibility: 'admin',
110
112
  });
111
113
  }
112
114
 
@@ -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 slock-style per-agent home + MEMORY.md for every paired
3
+ // agent in the linked Supabase project.
4
+ //
5
+ // ~/.ticlawk/agents/<agent_id>/MEMORY.md
6
+ //
7
+ // This replaces the Phase-B variant that wrote MEMORY.md into each
8
+ // agent's *project* workdir. The new design follows slock exactly:
9
+ // agent cwd = its own home dir, MEMORY.md lives in cwd.
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
+ });