@hoverlover/cc-discord 0.1.0
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/.claude/settings.template.json +94 -0
- package/.env.example +41 -0
- package/.env.relay.example +46 -0
- package/.env.worker.example +40 -0
- package/README.md +313 -0
- package/hooks/check-discord-messages.ts +204 -0
- package/hooks/cleanup-attachment.ts +47 -0
- package/hooks/safe-bash.ts +157 -0
- package/hooks/steer-send.ts +108 -0
- package/hooks/track-activity.ts +220 -0
- package/memory/README.md +60 -0
- package/memory/core/MemoryCoordinator.ts +703 -0
- package/memory/core/MemoryStore.ts +72 -0
- package/memory/core/session-key.ts +14 -0
- package/memory/core/types.ts +59 -0
- package/memory/index.ts +19 -0
- package/memory/providers/sqlite/SqliteMemoryStore.ts +838 -0
- package/memory/providers/sqlite/index.ts +1 -0
- package/package.json +45 -0
- package/prompts/autoreply-system.md +32 -0
- package/prompts/channel-system.md +22 -0
- package/prompts/orchestrator-system.md +56 -0
- package/scripts/channel-agent.sh +159 -0
- package/scripts/generate-settings.sh +17 -0
- package/scripts/load-env.sh +79 -0
- package/scripts/migrate-memory-to-channel-keys.ts +148 -0
- package/scripts/orchestrator.sh +325 -0
- package/scripts/parse-claude-stream.ts +349 -0
- package/scripts/start-orchestrator.sh +82 -0
- package/scripts/start-relay.sh +17 -0
- package/scripts/start.sh +175 -0
- package/server/attachment.ts +182 -0
- package/server/busy-notify.ts +69 -0
- package/server/config.ts +121 -0
- package/server/db.ts +249 -0
- package/server/index.ts +311 -0
- package/server/memory.ts +88 -0
- package/server/messages.ts +111 -0
- package/server/trace-thread.ts +340 -0
- package/server/typing.ts +101 -0
- package/tools/memory-inspect.ts +94 -0
- package/tools/memory-smoke.ts +173 -0
- package/tools/send-discord +2 -0
- package/tools/send-discord.ts +82 -0
- package/tools/wait-for-discord-messages +2 -0
- package/tools/wait-for-discord-messages.ts +369 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// Keep smoke output clean on Node builds where sqlite is marked experimental.
|
|
4
|
+
const _origEmit = process.emit;
|
|
5
|
+
process.emit = function (event: string, ...args: any[]) {
|
|
6
|
+
if (event === "warning" && args[0]?.name === "ExperimentalWarning") return false;
|
|
7
|
+
return _origEmit.call(this, event, ...args) as any;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { MemoryCoordinator } from "../memory/core/MemoryCoordinator.ts";
|
|
16
|
+
import { SqliteMemoryStore } from "../memory/providers/sqlite/SqliteMemoryStore.ts";
|
|
17
|
+
|
|
18
|
+
const dbPath = join(tmpdir(), `cc-discord-memory-smoke-${process.pid}-${Date.now()}.db`);
|
|
19
|
+
const store = new SqliteMemoryStore({ dbPath });
|
|
20
|
+
const coordinator = new MemoryCoordinator({ store, logger: console });
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await coordinator.init();
|
|
24
|
+
|
|
25
|
+
const health = await store.health();
|
|
26
|
+
assert.equal(health.ok, true);
|
|
27
|
+
|
|
28
|
+
const sessionKey = `smoke-${randomUUID()}`;
|
|
29
|
+
const batchId = `batch-${randomUUID()}`;
|
|
30
|
+
|
|
31
|
+
const write1 = await store.writeBatch({
|
|
32
|
+
batchId,
|
|
33
|
+
sessionKey,
|
|
34
|
+
agentId: "claude-test",
|
|
35
|
+
turns: [
|
|
36
|
+
{ role: "user", content: "Hello memory store" },
|
|
37
|
+
{ role: "assistant", content: "Hi, I remember that." },
|
|
38
|
+
],
|
|
39
|
+
snapshot: {
|
|
40
|
+
summaryText: "User greeted the assistant",
|
|
41
|
+
openTasks: ["Keep tracking context"],
|
|
42
|
+
decisions: ["Use sqlite primary memory store"],
|
|
43
|
+
},
|
|
44
|
+
cardsUpsert: [
|
|
45
|
+
{
|
|
46
|
+
cardType: "decision",
|
|
47
|
+
title: "Storage choice",
|
|
48
|
+
body: "Use sqlite as primary memory store",
|
|
49
|
+
pinned: true,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
assert.equal(write1.idempotent, false);
|
|
55
|
+
assert.equal(write1.counts.turns, 2);
|
|
56
|
+
assert.equal(write1.counts.snapshots, 1);
|
|
57
|
+
assert.equal(write1.counts.cardsUpserted, 1);
|
|
58
|
+
|
|
59
|
+
const write2 = await store.writeBatch({
|
|
60
|
+
batchId,
|
|
61
|
+
sessionKey,
|
|
62
|
+
turns: [{ role: "user", content: "This should not be written twice" }],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(write2.idempotent, true);
|
|
66
|
+
|
|
67
|
+
const snapshot = await store.readSessionSnapshot(sessionKey);
|
|
68
|
+
assert.ok(snapshot);
|
|
69
|
+
assert.equal(snapshot.summaryText, "User greeted the assistant");
|
|
70
|
+
|
|
71
|
+
const turns = await store.listTurns({ sessionKey, limit: 10 });
|
|
72
|
+
assert.equal(turns.length, 2);
|
|
73
|
+
assert.equal(turns[0].turnIndex, 0);
|
|
74
|
+
assert.equal(turns[1].turnIndex, 1);
|
|
75
|
+
|
|
76
|
+
const recentTurns = await store.listRecentTurns({ sessionKey, limit: 1 });
|
|
77
|
+
assert.equal(recentTurns.length, 1);
|
|
78
|
+
assert.equal(recentTurns[0].turnIndex, 1);
|
|
79
|
+
|
|
80
|
+
const cards = await store.queryCards({ sessionKey, limit: 10 });
|
|
81
|
+
assert.equal(cards.length, 1);
|
|
82
|
+
assert.equal(cards[0].cardType, "decision");
|
|
83
|
+
|
|
84
|
+
const runtimeState = await coordinator.ensureRuntimeContext({
|
|
85
|
+
sessionKey,
|
|
86
|
+
runtimeContextId: "runtime-current",
|
|
87
|
+
runtimeEpoch: 1,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await coordinator.appendTurn({
|
|
91
|
+
sessionKey,
|
|
92
|
+
role: "user",
|
|
93
|
+
content: "Legacy runtime detail: we chose sqlite for memory",
|
|
94
|
+
metadata: { runtimeContextId: "runtime-legacy", runtimeEpoch: 0 },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await coordinator.appendTurn({
|
|
98
|
+
sessionKey,
|
|
99
|
+
role: "user",
|
|
100
|
+
content: "Current runtime only detail: temporary debug flag is on",
|
|
101
|
+
metadata: {
|
|
102
|
+
runtimeContextId: runtimeState.runtimeContextId,
|
|
103
|
+
runtimeEpoch: runtimeState.runtimeEpoch,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const currentRuntimeTurn = (await store.listRecentTurns({ sessionKey, limit: 5 })).find((t: any) =>
|
|
108
|
+
/Current runtime only detail/.test(t.content),
|
|
109
|
+
);
|
|
110
|
+
assert.ok(currentRuntimeTurn);
|
|
111
|
+
|
|
112
|
+
const legacyContext = await coordinator.assembleContext({
|
|
113
|
+
sessionKey,
|
|
114
|
+
queryText: "what did we choose for memory backend?",
|
|
115
|
+
runtimeContextId: runtimeState.runtimeContextId,
|
|
116
|
+
runtimeEpoch: runtimeState.runtimeEpoch,
|
|
117
|
+
maxCards: 3,
|
|
118
|
+
maxRecallTurns: 4,
|
|
119
|
+
});
|
|
120
|
+
assert.ok(legacyContext);
|
|
121
|
+
assert.ok(Array.isArray(legacyContext.cards));
|
|
122
|
+
assert.equal(
|
|
123
|
+
legacyContext.recalledTurns.some((t: any) => /Legacy runtime detail/.test(t.content)),
|
|
124
|
+
true,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const filteredContext = await coordinator.assembleContext({
|
|
128
|
+
sessionKey,
|
|
129
|
+
queryText: "is temporary debug flag on?",
|
|
130
|
+
runtimeContextId: runtimeState.runtimeContextId,
|
|
131
|
+
runtimeEpoch: runtimeState.runtimeEpoch,
|
|
132
|
+
maxCards: 2,
|
|
133
|
+
maxRecallTurns: 6,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
assert.equal(
|
|
137
|
+
filteredContext.recalledTurns.some((t: any) => /Current runtime only detail/.test(t.content)),
|
|
138
|
+
false,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Compaction caveat: once current-runtime turn is compacted, it becomes eligible again.
|
|
142
|
+
await store.writeBatch({
|
|
143
|
+
batchId: `compact-${randomUUID()}`,
|
|
144
|
+
sessionKey,
|
|
145
|
+
compactedToTurnId: currentRuntimeTurn.id,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const postCompactionContext = await coordinator.assembleContext({
|
|
149
|
+
sessionKey,
|
|
150
|
+
queryText: "is temporary debug flag on?",
|
|
151
|
+
runtimeContextId: runtimeState.runtimeContextId,
|
|
152
|
+
runtimeEpoch: runtimeState.runtimeEpoch,
|
|
153
|
+
maxCards: 2,
|
|
154
|
+
maxRecallTurns: 6,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
assert.equal(
|
|
158
|
+
postCompactionContext.recalledTurns.some((t: any) => /Current runtime only detail/.test(t.content)),
|
|
159
|
+
true,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const contextText = coordinator.formatContextPacket(postCompactionContext);
|
|
163
|
+
assert.equal(typeof contextText, "string");
|
|
164
|
+
|
|
165
|
+
console.log(`Memory smoke test passed (db=${dbPath})`);
|
|
166
|
+
} finally {
|
|
167
|
+
await store.close().catch(() => {});
|
|
168
|
+
try {
|
|
169
|
+
rmSync(dbPath, { force: true });
|
|
170
|
+
} catch {
|
|
171
|
+
/* ignore */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Send a message to Discord via local relay.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* send-discord "hello world"
|
|
8
|
+
* send-discord --channel 1234567890 "hello"
|
|
9
|
+
* send-discord --reply 1234567890 "reply text"
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
let channelId: string | null = null;
|
|
14
|
+
let replyTo: string | null = null;
|
|
15
|
+
const textParts: string[] = [];
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
const arg = args[i];
|
|
19
|
+
if (arg === "--channel" && args[i + 1]) {
|
|
20
|
+
channelId = args[++i];
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (arg === "--reply" && args[i + 1]) {
|
|
24
|
+
replyTo = args[++i];
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
textParts.push(arg);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const content = textParts.join(" ").trim();
|
|
31
|
+
if (!content) {
|
|
32
|
+
console.error(`
|
|
33
|
+
Usage: send-discord [--channel <id>] [--reply <messageId>] "message"
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
send-discord "Build started"
|
|
37
|
+
send-discord --channel 123456789012345678 "Hello from Claude"
|
|
38
|
+
send-discord --reply 123456789012345678 "Thanks, on it"
|
|
39
|
+
`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const relayHost = process.env.RELAY_HOST || "127.0.0.1";
|
|
44
|
+
const relayPort = process.env.RELAY_PORT || "3199";
|
|
45
|
+
const relayUrl = process.env.RELAY_URL || `http://${relayHost}:${relayPort}`;
|
|
46
|
+
const apiToken = process.env.RELAY_API_TOKEN || "";
|
|
47
|
+
const fromAgent = process.env.AGENT_ID || process.env.CLAUDE_AGENT_ID || "claude";
|
|
48
|
+
|
|
49
|
+
const headers: Record<string, string> = {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
};
|
|
52
|
+
if (apiToken) {
|
|
53
|
+
headers["x-api-token"] = apiToken;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(`${relayUrl}/api/send`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers,
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
content,
|
|
62
|
+
channelId,
|
|
63
|
+
replyTo,
|
|
64
|
+
fromAgent,
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const body: any = await response.json().catch(() => ({}));
|
|
69
|
+
|
|
70
|
+
if (!response.ok || !body.success) {
|
|
71
|
+
console.error(`Failed to send Discord message: ${body.error || response.statusText}`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`Discord message sent (channel=${body.channelId}, messageId=${body.messageId})`);
|
|
76
|
+
process.exit(0);
|
|
77
|
+
} catch (err: unknown) {
|
|
78
|
+
console.error(`Failed to call relay at ${relayUrl}: ${(err as Error).message}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export {};
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wait for unread Discord messages for this agent/session.
|
|
5
|
+
*
|
|
6
|
+
* Notes:
|
|
7
|
+
* - Uses a PID lock so only one active poller exists per agent+session.
|
|
8
|
+
* - Default timeout exits 0 quietly (to avoid noisy background failures in Claude UI).
|
|
9
|
+
* - Use --strict-timeout to return exit code 1 on timeout.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* wait-for-discord-messages --agent claude --session default --timeout 600 [--deliver] [--strict-timeout]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Suppress Node.js ExperimentalWarning (SQLite) to keep output clean
|
|
16
|
+
const _origEmit = process.emit;
|
|
17
|
+
process.emit = function (event: string, ...args: any[]) {
|
|
18
|
+
if (event === "warning" && args[0]?.name === "ExperimentalWarning") return false;
|
|
19
|
+
return _origEmit.call(this, event, ...args) as any;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
import { Database as DatabaseSync } from "bun:sqlite";
|
|
23
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { MemoryCoordinator } from "../memory/core/MemoryCoordinator.ts";
|
|
27
|
+
import { buildMemorySessionKey } from "../memory/core/session-key.ts";
|
|
28
|
+
import { SqliteMemoryStore } from "../memory/providers/sqlite/SqliteMemoryStore.ts";
|
|
29
|
+
|
|
30
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const ROOT_DIR = process.env.ORCHESTRATOR_DIR || join(__dirname, "..");
|
|
32
|
+
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
let agentId = process.env.AGENT_ID || process.env.CLAUDE_AGENT_ID || "claude";
|
|
35
|
+
let sessionId = process.env.DISCORD_SESSION_ID || process.env.BROKER_SESSION_ID || process.env.SESSION_ID || "default";
|
|
36
|
+
let timeoutSeconds = 300;
|
|
37
|
+
let deliverMode = false;
|
|
38
|
+
let strictTimeout = false;
|
|
39
|
+
let quietTimeout = String(process.env.WAIT_QUIET_TIMEOUT || "true").toLowerCase() !== "false";
|
|
40
|
+
let channelFilter = process.env.DISCORD_CHANNEL_FILTER || "";
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < args.length; i++) {
|
|
43
|
+
if (args[i] === "--agent" && args[i + 1]) {
|
|
44
|
+
agentId = args[++i];
|
|
45
|
+
} else if (args[i] === "--session" && args[i + 1]) {
|
|
46
|
+
sessionId = args[++i];
|
|
47
|
+
} else if (args[i] === "--timeout" && args[i + 1]) {
|
|
48
|
+
timeoutSeconds = Number(args[++i]);
|
|
49
|
+
} else if (args[i] === "--channel" && args[i + 1]) {
|
|
50
|
+
channelFilter = args[++i];
|
|
51
|
+
} else if (args[i] === "--deliver") {
|
|
52
|
+
deliverMode = true;
|
|
53
|
+
} else if (args[i] === "--strict-timeout") {
|
|
54
|
+
strictTimeout = true;
|
|
55
|
+
} else if (args[i] === "--no-quiet-timeout") {
|
|
56
|
+
quietTimeout = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dbPath = join(ROOT_DIR, "data", "messages.db");
|
|
61
|
+
// When agent ID is a numeric channel ID (subagent mode), only match that exact ID.
|
|
62
|
+
// Otherwise use the legacy multi-target matching.
|
|
63
|
+
const isChannelAgent = /^\d{15,22}$/.test(agentId);
|
|
64
|
+
const targets = isChannelAgent ? [agentId] : [...new Set([agentId, agentId.replace(/-\d+$/, ""), "claude"])];
|
|
65
|
+
const placeholders = targets.map(() => "?").join(",");
|
|
66
|
+
|
|
67
|
+
const noopLogger = {
|
|
68
|
+
log() {},
|
|
69
|
+
warn() {},
|
|
70
|
+
error() {},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const TEMP_DIR = "/tmp/cc-discord";
|
|
74
|
+
mkdirSync(TEMP_DIR, { recursive: true });
|
|
75
|
+
|
|
76
|
+
const safeAgent = sanitizeForFilename(agentId);
|
|
77
|
+
const safeSession = sanitizeForFilename(sessionId);
|
|
78
|
+
const safeChannel = channelFilter ? `-ch${sanitizeForFilename(channelFilter)}` : "";
|
|
79
|
+
const lockFile = join(TEMP_DIR, `poller-${safeAgent}-${safeSession}${safeChannel}.lock`);
|
|
80
|
+
|
|
81
|
+
function sanitizeForFilename(input: string): string {
|
|
82
|
+
return String(input || "x").replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pidAlive(pid: number): boolean {
|
|
86
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function currentLockOwnerPid(): number | null {
|
|
96
|
+
try {
|
|
97
|
+
const raw = readFileSync(lockFile, "utf8").trim();
|
|
98
|
+
const pid = Number(raw);
|
|
99
|
+
return Number.isInteger(pid) ? pid : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function acquireLock(): boolean {
|
|
106
|
+
try {
|
|
107
|
+
writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
108
|
+
return true;
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
if (err.code !== "EEXIST") return false;
|
|
111
|
+
|
|
112
|
+
const existingPid = currentLockOwnerPid();
|
|
113
|
+
if (existingPid && existingPid !== process.pid && pidAlive(existingPid)) {
|
|
114
|
+
// Another poller for this agent/session is already active.
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Stale lock: reclaim.
|
|
119
|
+
try {
|
|
120
|
+
unlinkSync(lockFile);
|
|
121
|
+
} catch {
|
|
122
|
+
/* ignore */
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function cleanupLock() {
|
|
135
|
+
try {
|
|
136
|
+
const ownerPid = currentLockOwnerPid();
|
|
137
|
+
if (ownerPid === process.pid) {
|
|
138
|
+
unlinkSync(lockFile);
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// ignore
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!acquireLock()) {
|
|
146
|
+
// Another poller holds the lock. Output a message so Claude knows not to tight-loop retry.
|
|
147
|
+
const existingPid = currentLockOwnerPid();
|
|
148
|
+
console.log(
|
|
149
|
+
`POLLER_ACTIVE: Another poller (PID ${existingPid ?? "?"}) is already waiting for messages for agent ${agentId}. Messages will be delivered by the active poller. Do NOT call this tool again until the active poller returns with messages.`,
|
|
150
|
+
);
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
process.on("SIGTERM", () => {
|
|
155
|
+
cleanupLock();
|
|
156
|
+
process.exit(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
process.on("SIGINT", () => {
|
|
160
|
+
cleanupLock();
|
|
161
|
+
process.exit(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
process.on("exit", () => {
|
|
165
|
+
cleanupLock();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
async function buildMemoryContext(queryText: string): Promise<string> {
|
|
169
|
+
const memoryDbPath = join(ROOT_DIR, "data", "memory.db");
|
|
170
|
+
const memorySessionKey = buildMemorySessionKey({ sessionId, agentId, channelId: channelFilter || undefined });
|
|
171
|
+
const runtimeHint = process.env.CLAUDE_RUNTIME_ID || null;
|
|
172
|
+
|
|
173
|
+
let store: SqliteMemoryStore | undefined;
|
|
174
|
+
try {
|
|
175
|
+
store = new SqliteMemoryStore({ dbPath: memoryDbPath, logger: noopLogger });
|
|
176
|
+
const coordinator = new MemoryCoordinator({ store, logger: noopLogger });
|
|
177
|
+
await coordinator.init();
|
|
178
|
+
|
|
179
|
+
const runtimeState = await coordinator.ensureRuntimeContext({
|
|
180
|
+
sessionKey: memorySessionKey,
|
|
181
|
+
runtimeContextId: runtimeHint,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const packet = await coordinator.assembleContext({
|
|
185
|
+
sessionKey: memorySessionKey,
|
|
186
|
+
queryText,
|
|
187
|
+
runtimeContextId: runtimeState?.runtimeContextId,
|
|
188
|
+
runtimeEpoch: runtimeState?.runtimeEpoch,
|
|
189
|
+
includeSnapshot: true,
|
|
190
|
+
avoidCurrentRuntime: true,
|
|
191
|
+
activeWindowSize: 12,
|
|
192
|
+
maxCards: 6,
|
|
193
|
+
maxRecallTurns: 8,
|
|
194
|
+
maxTurnScan: 300,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return coordinator.formatContextPacket(packet);
|
|
198
|
+
} catch {
|
|
199
|
+
return "";
|
|
200
|
+
} finally {
|
|
201
|
+
if (store) {
|
|
202
|
+
try {
|
|
203
|
+
await store.close();
|
|
204
|
+
} catch {
|
|
205
|
+
/* ignore */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function checkCount(db: InstanceType<typeof DatabaseSync>): number {
|
|
212
|
+
const channelClause = channelFilter ? " AND channel_id = ?" : "";
|
|
213
|
+
const params: any[] = channelFilter ? [sessionId, ...targets, channelFilter] : [sessionId, ...targets];
|
|
214
|
+
const row = db
|
|
215
|
+
.prepare(`
|
|
216
|
+
SELECT COUNT(*) as count
|
|
217
|
+
FROM messages
|
|
218
|
+
WHERE session_id = ?
|
|
219
|
+
AND to_agent IN (${placeholders})
|
|
220
|
+
AND read = 0
|
|
221
|
+
${channelClause}
|
|
222
|
+
`)
|
|
223
|
+
.get(...params) as any;
|
|
224
|
+
return row.count;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function deliverMessages(db: InstanceType<typeof DatabaseSync>): Promise<boolean> {
|
|
228
|
+
db.exec("BEGIN IMMEDIATE");
|
|
229
|
+
const channelClause = channelFilter ? " AND channel_id = ?" : "";
|
|
230
|
+
const params: any[] = channelFilter ? [sessionId, ...targets, channelFilter] : [sessionId, ...targets];
|
|
231
|
+
const rows = db
|
|
232
|
+
.prepare(`
|
|
233
|
+
SELECT id, from_agent, message_type, content, channel_id
|
|
234
|
+
FROM messages
|
|
235
|
+
WHERE session_id = ?
|
|
236
|
+
AND to_agent IN (${placeholders})
|
|
237
|
+
AND read = 0
|
|
238
|
+
${channelClause}
|
|
239
|
+
ORDER BY id ASC
|
|
240
|
+
`)
|
|
241
|
+
.all(...params) as any[];
|
|
242
|
+
|
|
243
|
+
if (rows.length === 0) {
|
|
244
|
+
db.exec("COMMIT");
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const ids = rows.map((r: any) => r.id);
|
|
249
|
+
const idPlaceholders = ids.map(() => "?").join(",");
|
|
250
|
+
db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${idPlaceholders})`).run(...ids);
|
|
251
|
+
db.exec("COMMIT");
|
|
252
|
+
|
|
253
|
+
const formatted = rows.map((r: any) => {
|
|
254
|
+
const oneLine = String(r.content).replace(/\r/g, "").replace(/\n/g, " ");
|
|
255
|
+
const channelTag = r.channel_id ? ` [channel:${r.channel_id}]` : "";
|
|
256
|
+
return `[MESSAGE from ${r.from_agent}]${channelTag} [${r.message_type}]: ${oneLine}`;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const latestQueryText = String(rows[rows.length - 1]?.content || "");
|
|
260
|
+
const memoryText = await buildMemoryContext(latestQueryText);
|
|
261
|
+
const inboxText = `NEW DISCORD MESSAGE(S): ${formatted.join(" | ")}`;
|
|
262
|
+
const outputText = memoryText ? `${inboxText}\n\n${memoryText}` : inboxText;
|
|
263
|
+
|
|
264
|
+
console.log(outputText);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Write a heartbeat to agent_activity so the orchestrator/relay can detect
|
|
270
|
+
* stuck agents. Called on every poll cycle — even when no messages arrive.
|
|
271
|
+
*/
|
|
272
|
+
function writeHeartbeat(db: InstanceType<typeof DatabaseSync>, status: string = "polling") {
|
|
273
|
+
try {
|
|
274
|
+
db.prepare(`
|
|
275
|
+
INSERT INTO agent_activity (session_id, agent_id, status, activity_type, activity_summary, started_at, updated_at)
|
|
276
|
+
VALUES (?, ?, ?, 'heartbeat', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
277
|
+
ON CONFLICT(session_id, agent_id) DO UPDATE SET
|
|
278
|
+
status = excluded.status,
|
|
279
|
+
activity_type = 'heartbeat',
|
|
280
|
+
activity_summary = excluded.activity_summary,
|
|
281
|
+
updated_at = CURRENT_TIMESTAMP
|
|
282
|
+
`).run(sessionId, agentId, status, `poller pid=${process.pid}`);
|
|
283
|
+
} catch {
|
|
284
|
+
// Non-fatal; keep polling even if heartbeat write fails
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function main() {
|
|
289
|
+
let db: InstanceType<typeof DatabaseSync>;
|
|
290
|
+
try {
|
|
291
|
+
db = new DatabaseSync(dbPath);
|
|
292
|
+
} catch (err: unknown) {
|
|
293
|
+
console.error(`Cannot open database at ${dbPath}: ${(err as Error).message}`);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
process.on("exit", () => {
|
|
298
|
+
try {
|
|
299
|
+
db.close();
|
|
300
|
+
} catch {
|
|
301
|
+
/* ignore */
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
// Initial heartbeat
|
|
307
|
+
writeHeartbeat(db, "starting");
|
|
308
|
+
|
|
309
|
+
if (checkCount(db) > 0) {
|
|
310
|
+
writeHeartbeat(db, "delivering");
|
|
311
|
+
if (deliverMode) {
|
|
312
|
+
await deliverMessages(db);
|
|
313
|
+
} else {
|
|
314
|
+
console.log(`Messages pending for ${agentId}`);
|
|
315
|
+
}
|
|
316
|
+
process.exit(0);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const pollIntervalMs = 2000;
|
|
320
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
321
|
+
const start = Date.now();
|
|
322
|
+
|
|
323
|
+
let polling = false;
|
|
324
|
+
|
|
325
|
+
const tick = async () => {
|
|
326
|
+
if (polling) return;
|
|
327
|
+
polling = true;
|
|
328
|
+
try {
|
|
329
|
+
writeHeartbeat(db, "polling");
|
|
330
|
+
|
|
331
|
+
if (checkCount(db) > 0) {
|
|
332
|
+
writeHeartbeat(db, "delivering");
|
|
333
|
+
if (deliverMode) {
|
|
334
|
+
await deliverMessages(db);
|
|
335
|
+
} else {
|
|
336
|
+
console.log(`Messages pending for ${agentId}`);
|
|
337
|
+
}
|
|
338
|
+
process.exit(0);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (Date.now() - start > timeoutMs) {
|
|
342
|
+
if (!quietTimeout) {
|
|
343
|
+
console.log(`No messages after ${timeoutSeconds}s timeout`);
|
|
344
|
+
}
|
|
345
|
+
process.exit(strictTimeout ? 1 : 0);
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
// transient DB errors; keep polling
|
|
349
|
+
} finally {
|
|
350
|
+
polling = false;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const timer = setInterval(() => {
|
|
355
|
+
void tick();
|
|
356
|
+
}, pollIntervalMs);
|
|
357
|
+
|
|
358
|
+
// Also run once right away (in case race inserted message before first interval)
|
|
359
|
+
void tick();
|
|
360
|
+
|
|
361
|
+
process.on("exit", () => {
|
|
362
|
+
clearInterval(timer);
|
|
363
|
+
});
|
|
364
|
+
} catch {
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
await main();
|