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.
- package/README.md +13 -0
- package/bin/ticlawk.mjs +121 -0
- package/package.json +1 -1
- package/src/adapters/ticlawk/api.mjs +216 -6
- package/src/adapters/ticlawk/index.mjs +221 -71
- package/src/cli/agent-commands.mjs +545 -6
- package/src/core/agent-cli-handlers.mjs +416 -3
- package/src/core/agent-home.mjs +89 -0
- package/src/core/http.mjs +114 -0
- package/src/core/reminder-ticker.mjs +70 -0
- package/src/core/runtime-contract.mjs +1 -1
- package/src/core/runtime-support.mjs +31 -29
- package/src/core/ticlawk-control.mjs +3 -3
- package/src/migrate/write-initial-memory.mjs +101 -0
- package/src/runtimes/_shared/standing-prompt.mjs +277 -76
- package/src/runtimes/claude-code/index.mjs +8 -27
- package/src/runtimes/codex/index.mjs +15 -32
- package/src/runtimes/openclaw/index.mjs +34 -13
- package/src/runtimes/openclaw/target.mjs +0 -30
- package/src/runtimes/opencode/index.mjs +13 -31
- package/src/runtimes/pi/index.mjs +13 -32
- package/ticlawk.mjs +31 -6
|
@@ -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>} [
|
|
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 (
|
|
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
|
|
89
|
-
if (
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|