@inetafrica/open-claudia 2.0.1 → 2.0.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/bin/cli.js +98 -0
- package/bin/transcript-window.js +202 -0
- package/bot.js +9 -0
- package/channels/kazee/adapter.js +32 -10
- package/channels/telegram/adapter.js +38 -1
- package/core/config.js +3 -1
- package/core/loopback.js +123 -0
- package/core/router.js +8 -4
- package/core/runner.js +55 -24
- package/core/system-prompt.js +5 -1
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -29,6 +29,82 @@ function startBot(botFile, args) {
|
|
|
29
29
|
require(path.join(botDir, botFile));
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function sendViaLoopback(kind, restArgs) {
|
|
33
|
+
const http = require("http");
|
|
34
|
+
const filePath = restArgs[0];
|
|
35
|
+
if (!filePath) {
|
|
36
|
+
console.error(`Usage: open-claudia ${kind} <path>${kind === "send-voice" ? "" : " [caption]"}`);
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
const caption = restArgs.slice(1).join(" ");
|
|
40
|
+
if (!fs.existsSync(filePath)) {
|
|
41
|
+
console.error(`File not found: ${filePath}`);
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const channelId = process.env.OC_CHANNEL_ID;
|
|
46
|
+
const adapterId = process.env.OC_CHANNEL_ADAPTER;
|
|
47
|
+
let sendUrl = process.env.OC_SEND_URL;
|
|
48
|
+
let sendToken = process.env.OC_SEND_TOKEN;
|
|
49
|
+
|
|
50
|
+
if (!sendUrl || !sendToken) {
|
|
51
|
+
const info = readLoopbackInfo();
|
|
52
|
+
if (info) { sendUrl = info.url; sendToken = info.token; }
|
|
53
|
+
}
|
|
54
|
+
if (!sendUrl || !sendToken || !channelId || !adapterId) {
|
|
55
|
+
console.error("No active chat context. This command only works from inside a running Claude task spawned by the bot.");
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const u = new URL(sendUrl);
|
|
60
|
+
const fileName = path.basename(filePath);
|
|
61
|
+
const stat = fs.statSync(filePath);
|
|
62
|
+
const params = new URLSearchParams({ channelId, adapter: adapterId, fileName });
|
|
63
|
+
if (caption) params.set("caption", caption);
|
|
64
|
+
|
|
65
|
+
const req = http.request({
|
|
66
|
+
method: "POST",
|
|
67
|
+
hostname: u.hostname,
|
|
68
|
+
port: u.port,
|
|
69
|
+
path: `/${kind}?${params.toString()}`,
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${sendToken}`,
|
|
72
|
+
"Content-Type": "application/octet-stream",
|
|
73
|
+
"Content-Length": stat.size,
|
|
74
|
+
},
|
|
75
|
+
}, (res) => {
|
|
76
|
+
let body = "";
|
|
77
|
+
res.on("data", (c) => { body += c; });
|
|
78
|
+
res.on("end", () => {
|
|
79
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
80
|
+
console.log(`Sent ${fileName} (${stat.size} bytes).`);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
} else {
|
|
83
|
+
console.error(`Loopback ${res.statusCode}: ${body}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
req.on("error", (e) => { console.error("Loopback request failed:", e.message); process.exit(1); });
|
|
89
|
+
fs.createReadStream(filePath).pipe(req);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readLoopbackInfo() {
|
|
93
|
+
try {
|
|
94
|
+
const dir = path.join(configDir, "loopback");
|
|
95
|
+
if (!fs.existsSync(dir)) return null;
|
|
96
|
+
const entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
97
|
+
let newest = null;
|
|
98
|
+
for (const f of entries) {
|
|
99
|
+
const full = path.join(dir, f);
|
|
100
|
+
const stat = fs.statSync(full);
|
|
101
|
+
if (!newest || stat.mtimeMs > newest.mtimeMs) newest = { full, mtimeMs: stat.mtimeMs };
|
|
102
|
+
}
|
|
103
|
+
if (!newest) return null;
|
|
104
|
+
return JSON.parse(fs.readFileSync(newest.full, "utf-8"));
|
|
105
|
+
} catch (e) { return null; }
|
|
106
|
+
}
|
|
107
|
+
|
|
32
108
|
function findBotProcesses() {
|
|
33
109
|
try {
|
|
34
110
|
if (process.platform === "win32") {
|
|
@@ -150,6 +226,19 @@ switch (command) {
|
|
|
150
226
|
break;
|
|
151
227
|
}
|
|
152
228
|
|
|
229
|
+
case "send-file":
|
|
230
|
+
case "send-photo":
|
|
231
|
+
case "send-voice": {
|
|
232
|
+
sendViaLoopback(command, args.slice(1));
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
case "tw":
|
|
237
|
+
case "transcript-window": {
|
|
238
|
+
require("./transcript-window").run(args.slice(1));
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
|
|
153
242
|
default:
|
|
154
243
|
console.log(`
|
|
155
244
|
Open Claudia — AI Coding Assistant via Telegram
|
|
@@ -164,6 +253,15 @@ Commands:
|
|
|
164
253
|
open-claudia health Run environment health checks
|
|
165
254
|
open-claudia logs View recent logs
|
|
166
255
|
|
|
256
|
+
Send tools (only work inside an active bot-spawned task):
|
|
257
|
+
open-claudia send-file <path> [caption]
|
|
258
|
+
open-claudia send-photo <path> [caption]
|
|
259
|
+
open-claudia send-voice <path>
|
|
260
|
+
|
|
261
|
+
Memory tools:
|
|
262
|
+
open-claudia transcript-window <pattern> Search project transcript, show hits with context
|
|
263
|
+
(alias: tw; --help for options)
|
|
264
|
+
|
|
167
265
|
Start options:
|
|
168
266
|
--web Also start the web UI
|
|
169
267
|
--quick Skip slow health checks (Claude auth, Telegram API)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Search the project transcript JSONL for a pattern and print each hit
|
|
2
|
+
// with surrounding turns of context, capped to keep the output bounded.
|
|
3
|
+
//
|
|
4
|
+
// One JSONL line = one user or assistant turn. Output is plain text:
|
|
5
|
+
// match header, separator-delimited context blocks (before, HIT, after).
|
|
6
|
+
//
|
|
7
|
+
// Path resolution:
|
|
8
|
+
// 1. --path <file> flag
|
|
9
|
+
// 2. OC_TRANSCRIPT_PATH env (injected by core/runner.js for bot-spawned tasks)
|
|
10
|
+
// 3. error out — we don't try to guess
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
before: 2,
|
|
16
|
+
after: 2,
|
|
17
|
+
maxTurns: 10,
|
|
18
|
+
maxChars: 1200,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const out = {
|
|
23
|
+
pattern: null,
|
|
24
|
+
before: DEFAULTS.before,
|
|
25
|
+
after: DEFAULTS.after,
|
|
26
|
+
maxTurns: DEFAULTS.maxTurns,
|
|
27
|
+
maxChars: DEFAULTS.maxChars,
|
|
28
|
+
regex: false,
|
|
29
|
+
path: null,
|
|
30
|
+
json: false,
|
|
31
|
+
help: false,
|
|
32
|
+
};
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const a = argv[i];
|
|
35
|
+
if (a === "-h" || a === "--help") { out.help = true; continue; }
|
|
36
|
+
if (a === "--regex") { out.regex = true; continue; }
|
|
37
|
+
if (a === "--json") { out.json = true; continue; }
|
|
38
|
+
if (a === "--before") { out.before = parseInt(argv[++i], 10); continue; }
|
|
39
|
+
if (a === "--after") { out.after = parseInt(argv[++i], 10); continue; }
|
|
40
|
+
if (a === "--max-turns") { out.maxTurns = parseInt(argv[++i], 10); continue; }
|
|
41
|
+
if (a === "--max-chars") { out.maxChars = parseInt(argv[++i], 10); continue; }
|
|
42
|
+
if (a === "--path") { out.path = argv[++i]; continue; }
|
|
43
|
+
if (a.startsWith("--")) {
|
|
44
|
+
console.error(`Unknown flag: ${a}`);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
if (out.pattern === null) { out.pattern = a; continue; }
|
|
48
|
+
console.error(`Unexpected argument: ${a}`);
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
if (!Number.isFinite(out.before) || out.before < 0) out.before = DEFAULTS.before;
|
|
52
|
+
if (!Number.isFinite(out.after) || out.after < 0) out.after = DEFAULTS.after;
|
|
53
|
+
if (!Number.isFinite(out.maxTurns) || out.maxTurns < 1) out.maxTurns = DEFAULTS.maxTurns;
|
|
54
|
+
if (!Number.isFinite(out.maxChars) || out.maxChars < 100) out.maxChars = DEFAULTS.maxChars;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printHelp() {
|
|
59
|
+
console.log(`Usage: open-claudia transcript-window <pattern> [options]
|
|
60
|
+
|
|
61
|
+
Search the active project transcript and print each hit with surrounding turns.
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
--before N Turns of context before each hit (default ${DEFAULTS.before})
|
|
65
|
+
--after N Turns of context after each hit (default ${DEFAULTS.after})
|
|
66
|
+
--max-turns K Stop after K hits (default ${DEFAULTS.maxTurns})
|
|
67
|
+
--max-chars C Cap per-turn text length (default ${DEFAULTS.maxChars})
|
|
68
|
+
--regex Treat pattern as a regex (default: literal, case-insensitive)
|
|
69
|
+
--path <file> Transcript path (default: OC_TRANSCRIPT_PATH env)
|
|
70
|
+
--json Emit JSONL of matched windows instead of pretty text
|
|
71
|
+
|
|
72
|
+
Exit codes: 0 hits found, 1 no hits, 2 usage error, 3 transcript unreadable.`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveTranscriptPath(flagPath) {
|
|
76
|
+
if (flagPath) return flagPath;
|
|
77
|
+
if (process.env.OC_TRANSCRIPT_PATH) return process.env.OC_TRANSCRIPT_PATH;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function loadTurns(transcriptPath) {
|
|
82
|
+
const raw = fs.readFileSync(transcriptPath, "utf-8");
|
|
83
|
+
const turns = [];
|
|
84
|
+
let lineNo = 0;
|
|
85
|
+
for (const line of raw.split("\n")) {
|
|
86
|
+
lineNo++;
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
try {
|
|
89
|
+
const obj = JSON.parse(line);
|
|
90
|
+
turns.push({
|
|
91
|
+
lineNo,
|
|
92
|
+
timestamp: obj.timestamp || "",
|
|
93
|
+
role: obj.role || "",
|
|
94
|
+
text: typeof obj.text === "string" ? obj.text : "",
|
|
95
|
+
sessionId: obj.sessionId || "",
|
|
96
|
+
});
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// skip malformed line
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return turns;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildMatcher(pattern, regex) {
|
|
105
|
+
if (regex) {
|
|
106
|
+
try { return new RegExp(pattern); }
|
|
107
|
+
catch (e) {
|
|
108
|
+
console.error(`Invalid regex: ${e.message}`);
|
|
109
|
+
process.exit(2);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const lc = pattern.toLowerCase();
|
|
113
|
+
return { test: (s) => s.toLowerCase().includes(lc) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function truncate(text, maxChars) {
|
|
117
|
+
if (text.length <= maxChars) return text;
|
|
118
|
+
return text.slice(0, maxChars) + `\n[...truncated, ${text.length - maxChars} more chars]`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatTurn(turn, label, maxChars) {
|
|
122
|
+
return [
|
|
123
|
+
`--- ${label} line ${turn.lineNo} ${turn.timestamp} ${turn.role} ---`,
|
|
124
|
+
truncate(turn.text, maxChars),
|
|
125
|
+
].join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function run(argv) {
|
|
129
|
+
const opts = parseArgs(argv);
|
|
130
|
+
if (opts.help || !opts.pattern) {
|
|
131
|
+
printHelp();
|
|
132
|
+
process.exit(opts.help ? 0 : 2);
|
|
133
|
+
}
|
|
134
|
+
const transcriptPath = resolveTranscriptPath(opts.path);
|
|
135
|
+
if (!transcriptPath) {
|
|
136
|
+
console.error("No transcript path. Pass --path <file>, or run inside an active bot-spawned task (OC_TRANSCRIPT_PATH).");
|
|
137
|
+
process.exit(2);
|
|
138
|
+
}
|
|
139
|
+
if (!fs.existsSync(transcriptPath)) {
|
|
140
|
+
console.error(`Transcript not found: ${transcriptPath}`);
|
|
141
|
+
process.exit(3);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let turns;
|
|
145
|
+
try { turns = loadTurns(transcriptPath); }
|
|
146
|
+
catch (e) {
|
|
147
|
+
console.error(`Failed to read transcript: ${e.message}`);
|
|
148
|
+
process.exit(3);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const matcher = buildMatcher(opts.pattern, opts.regex);
|
|
152
|
+
const hits = [];
|
|
153
|
+
for (let i = 0; i < turns.length; i++) {
|
|
154
|
+
if (matcher.test(turns[i].text)) {
|
|
155
|
+
hits.push(i);
|
|
156
|
+
if (hits.length >= opts.maxTurns) break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (hits.length === 0) {
|
|
161
|
+
console.error(`No matches for ${JSON.stringify(opts.pattern)} in ${transcriptPath}`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (opts.json) {
|
|
166
|
+
for (let h = 0; h < hits.length; h++) {
|
|
167
|
+
const idx = hits[h];
|
|
168
|
+
const window = [];
|
|
169
|
+
for (let j = Math.max(0, idx - opts.before); j <= Math.min(turns.length - 1, idx + opts.after); j++) {
|
|
170
|
+
window.push({ ...turns[j], offset: j - idx });
|
|
171
|
+
}
|
|
172
|
+
process.stdout.write(JSON.stringify({ matchIndex: h + 1, matchLine: turns[idx].lineNo, window }) + "\n");
|
|
173
|
+
}
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(`Transcript: ${transcriptPath}`);
|
|
178
|
+
console.log(`Pattern: ${JSON.stringify(opts.pattern)}${opts.regex ? " (regex)" : ""}`);
|
|
179
|
+
console.log(`Hits: ${hits.length} Window: -${opts.before}/+${opts.after} turns Per-turn cap: ${opts.maxChars} chars`);
|
|
180
|
+
console.log("");
|
|
181
|
+
|
|
182
|
+
for (let h = 0; h < hits.length; h++) {
|
|
183
|
+
const idx = hits[h];
|
|
184
|
+
const start = Math.max(0, idx - opts.before);
|
|
185
|
+
const end = Math.min(turns.length - 1, idx + opts.after);
|
|
186
|
+
console.log(`===== match ${h + 1}/${hits.length} line ${turns[idx].lineNo} =====`);
|
|
187
|
+
for (let j = start; j <= end; j++) {
|
|
188
|
+
const offset = j - idx;
|
|
189
|
+
const label = offset === 0 ? "HIT" : (offset < 0 ? `ctx ${offset}` : `ctx +${offset}`);
|
|
190
|
+
console.log(formatTurn(turns[j], label, opts.maxChars));
|
|
191
|
+
}
|
|
192
|
+
console.log("");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { run, parseArgs, loadTurns };
|
|
199
|
+
|
|
200
|
+
if (require.main === module) {
|
|
201
|
+
run(process.argv.slice(2));
|
|
202
|
+
}
|
package/bot.js
CHANGED
|
@@ -17,6 +17,7 @@ const { initCrons } = require("./core/cron");
|
|
|
17
17
|
const { onMessage, onAction } = require("./core/router");
|
|
18
18
|
const { publicCommands } = require("./core/commands");
|
|
19
19
|
const registry = require("./core/adapter-registry");
|
|
20
|
+
const loopback = require("./core/loopback");
|
|
20
21
|
require("./core/handlers"); // side-effect: register slash commands
|
|
21
22
|
|
|
22
23
|
const CURRENT_VERSION = require(path.join(__dirname, "package.json")).version;
|
|
@@ -42,6 +43,7 @@ async function gracefulShutdown(signal) {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
for (const a of adapters) { try { await a.stop(); } catch (e) {} }
|
|
46
|
+
try { loopback.stop(); } catch (e) {}
|
|
45
47
|
try {
|
|
46
48
|
const mediaDir = path.join(CONFIG_DIR, "media");
|
|
47
49
|
if (fs.existsSync(mediaDir)) {
|
|
@@ -159,6 +161,13 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
159
161
|
|
|
160
162
|
initCrons();
|
|
161
163
|
|
|
164
|
+
try {
|
|
165
|
+
const lb = await loopback.start(registry);
|
|
166
|
+
console.log(`Loopback send endpoint: ${lb.url}`);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.error("Loopback start failed:", e.message);
|
|
169
|
+
}
|
|
170
|
+
|
|
162
171
|
for (const adapter of adapters) {
|
|
163
172
|
try {
|
|
164
173
|
await adapter.start();
|
|
@@ -121,13 +121,16 @@ class KazeeAdapter {
|
|
|
121
121
|
|
|
122
122
|
const text = (msg.content || "").toString();
|
|
123
123
|
const isCommand = text.trim().startsWith("/");
|
|
124
|
-
|
|
124
|
+
// chat-central's message.type enum has no "voice" — voice notes arrive
|
|
125
|
+
// as type "audio". Map the channel types we accept onto router types.
|
|
126
|
+
const KNOWN_TYPES = { voice: "voice", audio: "audio", image: "photo", video: "document", file: "document" };
|
|
127
|
+
const routerType = isCommand ? "command" : (KNOWN_TYPES[msg.type] || "text");
|
|
125
128
|
const envelope = {
|
|
126
129
|
adapter: this,
|
|
127
130
|
channelId,
|
|
128
131
|
canonicalUserId: canonicalForChannel("kazee", userId),
|
|
129
132
|
userId,
|
|
130
|
-
type:
|
|
133
|
+
type: routerType,
|
|
131
134
|
text,
|
|
132
135
|
messageId: msg._id || msg.id,
|
|
133
136
|
replyToId: msg.replyTo,
|
|
@@ -136,14 +139,28 @@ class KazeeAdapter {
|
|
|
136
139
|
};
|
|
137
140
|
|
|
138
141
|
if (msg.attachments && msg.attachments.length) {
|
|
139
|
-
envelope.media = msg.attachments.map((a) =>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
envelope.media = msg.attachments.map((a) => {
|
|
143
|
+
let kind = a.type;
|
|
144
|
+
if (!kind) {
|
|
145
|
+
const mime = (a.mimeType || "").toLowerCase();
|
|
146
|
+
if (mime.startsWith("image/")) kind = "photo";
|
|
147
|
+
else if (mime.startsWith("audio/")) kind = "audio";
|
|
148
|
+
else if (mime.startsWith("video/")) kind = "video";
|
|
149
|
+
else kind = "document";
|
|
150
|
+
} else if (kind === "image") kind = "photo";
|
|
151
|
+
else if (kind === "file") kind = "document";
|
|
152
|
+
return {
|
|
153
|
+
type: kind,
|
|
154
|
+
fileId: a.url || a._id,
|
|
155
|
+
fileName: a.name || a.filename,
|
|
156
|
+
mimeType: a.mimeType,
|
|
157
|
+
size: a.size,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
if (envelope.type === "text") {
|
|
161
|
+
const k = envelope.media[0].type;
|
|
162
|
+
envelope.type = k === "video" ? "document" : k;
|
|
163
|
+
}
|
|
147
164
|
}
|
|
148
165
|
|
|
149
166
|
this._emit("message", envelope);
|
|
@@ -203,6 +220,11 @@ class KazeeAdapter {
|
|
|
203
220
|
return ok;
|
|
204
221
|
}
|
|
205
222
|
|
|
223
|
+
async sendPhoto(channelId, filePath, caption) {
|
|
224
|
+
// Kazee's sendFile picks the right message type by extension already.
|
|
225
|
+
return this.sendFile(channelId, filePath, caption);
|
|
226
|
+
}
|
|
227
|
+
|
|
206
228
|
async sendFile(channelId, filePath, caption) {
|
|
207
229
|
try {
|
|
208
230
|
const buffer = fs.readFileSync(filePath);
|
|
@@ -92,7 +92,34 @@ class TelegramAdapter {
|
|
|
92
92
|
this.bot.on("audio", (msg) => this._emit("message", this._envelopeMedia(msg, "audio", msg.audio)));
|
|
93
93
|
this.bot.on("photo", (msg) => {
|
|
94
94
|
const photo = msg.photo[msg.photo.length - 1];
|
|
95
|
-
|
|
95
|
+
if (!msg.media_group_id) {
|
|
96
|
+
this._emit("message", this._envelopeMedia(msg, "photo", photo));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Telegram delivers an album as N separate `photo` updates sharing
|
|
100
|
+
// the same media_group_id. Buffer briefly so the handler gets one
|
|
101
|
+
// envelope with media[] of all photos instead of N separate turns.
|
|
102
|
+
if (!this._photoGroups) this._photoGroups = new Map();
|
|
103
|
+
let batch = this._photoGroups.get(msg.media_group_id);
|
|
104
|
+
if (!batch) {
|
|
105
|
+
batch = { firstMsg: msg, items: [], timer: null };
|
|
106
|
+
this._photoGroups.set(msg.media_group_id, batch);
|
|
107
|
+
}
|
|
108
|
+
if (msg.caption && !batch.firstMsg.caption) batch.firstMsg.caption = msg.caption;
|
|
109
|
+
batch.items.push({
|
|
110
|
+
type: "photo",
|
|
111
|
+
fileId: photo.file_id,
|
|
112
|
+
fileName: photo.file_name,
|
|
113
|
+
mimeType: photo.mime_type,
|
|
114
|
+
size: photo.file_size,
|
|
115
|
+
});
|
|
116
|
+
if (batch.timer) clearTimeout(batch.timer);
|
|
117
|
+
batch.timer = setTimeout(() => {
|
|
118
|
+
this._photoGroups.delete(msg.media_group_id);
|
|
119
|
+
const env = this._envelopeMedia(batch.firstMsg, "photo", photo);
|
|
120
|
+
env.media = batch.items;
|
|
121
|
+
this._emit("message", env);
|
|
122
|
+
}, 600);
|
|
96
123
|
});
|
|
97
124
|
this.bot.on("document", (msg) => this._emit("message", this._envelopeMedia(msg, "document", msg.document)));
|
|
98
125
|
|
|
@@ -251,6 +278,16 @@ class TelegramAdapter {
|
|
|
251
278
|
}
|
|
252
279
|
}
|
|
253
280
|
|
|
281
|
+
async sendPhoto(channelId, filePath, caption) {
|
|
282
|
+
try {
|
|
283
|
+
await this.bot.sendPhoto(channelId, filePath, caption ? { caption } : {});
|
|
284
|
+
return true;
|
|
285
|
+
} catch (e) {
|
|
286
|
+
console.error("Send photo error:", e.message);
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
254
291
|
async typing(channelId) {
|
|
255
292
|
try { await this.bot.sendChatAction(channelId, "typing"); } catch (e) {}
|
|
256
293
|
}
|
package/core/config.js
CHANGED
|
@@ -47,7 +47,8 @@ const WORKSPACE = config.WORKSPACE;
|
|
|
47
47
|
const CLAUDE_PATH = config.CLAUDE_PATH;
|
|
48
48
|
const CURSOR_PATH = config.CURSOR_PATH || null;
|
|
49
49
|
const CODEX_PATH = config.CODEX_PATH || null;
|
|
50
|
-
const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "
|
|
50
|
+
const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
|
|
51
|
+
const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
|
|
51
52
|
const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
|
|
52
53
|
const TRANSCRIPT_MAX_ENTRY_CHARS = parseInt(config.TRANSCRIPT_MAX_ENTRY_CHARS || process.env.TRANSCRIPT_MAX_ENTRY_CHARS || "12000", 10);
|
|
53
54
|
const TRANSCRIPTS_DIR = config.TRANSCRIPTS_DIR || process.env.TRANSCRIPTS_DIR || path.join(CONFIG_DIR, "transcripts");
|
|
@@ -185,6 +186,7 @@ module.exports = {
|
|
|
185
186
|
resolvedCursorPath,
|
|
186
187
|
resolvedCodexPath,
|
|
187
188
|
AUTO_COMPACT_TOKENS,
|
|
189
|
+
MIN_COMPACT_INTERVAL_MS,
|
|
188
190
|
PROJECT_TRANSCRIPTS,
|
|
189
191
|
TRANSCRIPT_MAX_ENTRY_CHARS,
|
|
190
192
|
TRANSCRIPTS_DIR,
|
package/core/loopback.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Bot ↔ subprocess send channel. Each running bot publishes a tiny HTTP
|
|
2
|
+
// server on 127.0.0.1:<random-port> and writes {port, token} to a
|
|
3
|
+
// per-PID file under CONFIG_DIR/loopback. Subprocesses spawned through
|
|
4
|
+
// runClaude inherit OC_SEND_URL, OC_SEND_TOKEN, OC_CHANNEL_ID, and
|
|
5
|
+
// OC_CHANNEL_ADAPTER in their env; the `open-claudia send-file /
|
|
6
|
+
// send-voice / send-photo` CLI subcommands POST a file back through
|
|
7
|
+
// this endpoint so the active channel's adapter can deliver it.
|
|
8
|
+
//
|
|
9
|
+
// Bound to 127.0.0.1 only and gated by a random token so other local
|
|
10
|
+
// processes can't impersonate the bot.
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const http = require("http");
|
|
15
|
+
const os = require("os");
|
|
16
|
+
const crypto = require("crypto");
|
|
17
|
+
const { CONFIG_DIR } = require("./config");
|
|
18
|
+
|
|
19
|
+
const INFO_DIR = path.join(CONFIG_DIR, "loopback");
|
|
20
|
+
const INFO_FILE = path.join(INFO_DIR, `${process.pid}.json`);
|
|
21
|
+
|
|
22
|
+
let server = null;
|
|
23
|
+
let token = null;
|
|
24
|
+
let registry = null;
|
|
25
|
+
|
|
26
|
+
function info() {
|
|
27
|
+
if (!server) return null;
|
|
28
|
+
const addr = server.address();
|
|
29
|
+
return { port: addr.port, token, pid: process.pid, url: `http://127.0.0.1:${addr.port}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeInfo() {
|
|
33
|
+
fs.mkdirSync(INFO_DIR, { recursive: true });
|
|
34
|
+
fs.writeFileSync(INFO_FILE, JSON.stringify(info(), null, 2), { mode: 0o600 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function deleteInfo() {
|
|
38
|
+
try { fs.unlinkSync(INFO_FILE); } catch (e) {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function reply(res, code, body) {
|
|
42
|
+
const data = JSON.stringify(body);
|
|
43
|
+
res.writeHead(code, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) });
|
|
44
|
+
res.end(data);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function authOk(req) {
|
|
48
|
+
return (req.headers["authorization"] || "") === `Bearer ${token}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readBodyToFile(req, dest) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const out = fs.createWriteStream(dest);
|
|
54
|
+
req.pipe(out);
|
|
55
|
+
out.on("finish", () => resolve());
|
|
56
|
+
out.on("error", reject);
|
|
57
|
+
req.on("error", reject);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const KINDS = new Set(["send-file", "send-voice", "send-photo"]);
|
|
62
|
+
|
|
63
|
+
async function handle(req, res) {
|
|
64
|
+
try {
|
|
65
|
+
if (req.method !== "POST") return reply(res, 405, { error: "method not allowed" });
|
|
66
|
+
if (!authOk(req)) return reply(res, 401, { error: "unauthorized" });
|
|
67
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
68
|
+
const kind = url.pathname.slice(1);
|
|
69
|
+
if (!KINDS.has(kind)) return reply(res, 404, { error: "not found" });
|
|
70
|
+
const channelId = url.searchParams.get("channelId");
|
|
71
|
+
const adapterId = url.searchParams.get("adapter");
|
|
72
|
+
const caption = url.searchParams.get("caption") || "";
|
|
73
|
+
const fileName = url.searchParams.get("fileName") || `file-${Date.now()}.bin`;
|
|
74
|
+
if (!channelId || !adapterId) return reply(res, 400, { error: "missing channelId/adapter" });
|
|
75
|
+
const adapter = registry.findAdapter(adapterId);
|
|
76
|
+
if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
|
|
77
|
+
|
|
78
|
+
const safeName = path.basename(fileName).replace(/[^A-Za-z0-9._-]/g, "_") || `file-${Date.now()}`;
|
|
79
|
+
const tmp = path.join(os.tmpdir(), `oc-loopback-${process.pid}-${Date.now()}-${safeName}`);
|
|
80
|
+
await readBodyToFile(req, tmp);
|
|
81
|
+
|
|
82
|
+
let ok = false;
|
|
83
|
+
try {
|
|
84
|
+
if (kind === "send-voice") {
|
|
85
|
+
ok = await adapter.sendVoice(channelId, tmp); // adapter unlinks on success/failure
|
|
86
|
+
} else if (kind === "send-photo" && typeof adapter.sendPhoto === "function") {
|
|
87
|
+
ok = await adapter.sendPhoto(channelId, tmp, caption);
|
|
88
|
+
} else {
|
|
89
|
+
ok = await adapter.sendFile(channelId, tmp, caption);
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
|
|
93
|
+
}
|
|
94
|
+
return reply(res, ok ? 200 : 500, { ok });
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error("loopback handle error:", e.message);
|
|
97
|
+
return reply(res, 500, { error: e.message });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function start(adapterRegistry) {
|
|
102
|
+
if (server) return Promise.resolve(info());
|
|
103
|
+
registry = adapterRegistry;
|
|
104
|
+
token = crypto.randomBytes(24).toString("hex");
|
|
105
|
+
server = http.createServer(handle);
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
server.once("error", reject);
|
|
108
|
+
server.listen(0, "127.0.0.1", () => {
|
|
109
|
+
writeInfo();
|
|
110
|
+
resolve(info());
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stop() {
|
|
116
|
+
if (server) try { server.close(); } catch (e) {}
|
|
117
|
+
server = null;
|
|
118
|
+
deleteInfo();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
process.on("exit", deleteInfo);
|
|
122
|
+
|
|
123
|
+
module.exports = { start, stop, info, INFO_DIR, INFO_FILE };
|
package/core/router.js
CHANGED
|
@@ -145,11 +145,15 @@ async function handlePhoto(envelope) {
|
|
|
145
145
|
const state = currentState();
|
|
146
146
|
if (!state.currentSession) return send("Pick a project first.");
|
|
147
147
|
try {
|
|
148
|
-
const
|
|
149
|
-
if (!
|
|
150
|
-
const
|
|
148
|
+
const items = envelope.media || [];
|
|
149
|
+
if (!items.length) return;
|
|
150
|
+
const paths = [];
|
|
151
|
+
for (const m of items) paths.push(await envelope.adapter.downloadMedia(m));
|
|
151
152
|
const caption = envelope.caption || "Describe this image. If code/UI/error — explain and fix.";
|
|
152
|
-
|
|
153
|
+
const prompt = paths.length === 1
|
|
154
|
+
? `Image at ${paths[0]}\n\nView it, then: ${caption}`
|
|
155
|
+
: `Images:\n${paths.map((p, i) => `${i + 1}. ${p}`).join("\n")}\n\nView all ${paths.length}, then: ${caption}`;
|
|
156
|
+
await runClaude(prompt, state.currentSession.dir, envelope.messageId);
|
|
153
157
|
} catch (err) { await send(`Image failed: ${err.message}`); }
|
|
154
158
|
}
|
|
155
159
|
|
package/core/runner.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const { spawn } = require("child_process");
|
|
7
7
|
const {
|
|
8
8
|
CLAUDE_PATH, resolvedCursorPath, resolvedCodexPath,
|
|
9
|
-
AUTO_COMPACT_TOKENS, MAX_PROCESS_TIMEOUT, botSubprocessEnv,
|
|
9
|
+
AUTO_COMPACT_TOKENS, MIN_COMPACT_INTERVAL_MS, MAX_PROCESS_TIMEOUT, botSubprocessEnv,
|
|
10
10
|
} = require("./config");
|
|
11
11
|
const { currentState, saveState, recordSession, userOwnsClaudeSession } = require("./state");
|
|
12
12
|
const { chatContext, currentChannelId, currentAdapter } = require("./context");
|
|
@@ -20,6 +20,27 @@ const {
|
|
|
20
20
|
promptWithTranscriptPointer, stripTranscriptPointerForStorage,
|
|
21
21
|
} = require("./transcripts");
|
|
22
22
|
const { getClaudeOAuthToken, claudeAuthRecoveryMessage, isClaudeAuthErrorText, claudeUsageLimitMessage, isClaudeUsageLimitText, runClaudeAuthStatusDiagnostic, claudeSubprocessEnv } = require("./auth-flow");
|
|
23
|
+
const loopback = require("./loopback");
|
|
24
|
+
|
|
25
|
+
function chatEnvOverlay() {
|
|
26
|
+
const adapter = currentAdapter();
|
|
27
|
+
const channelId = currentChannelId();
|
|
28
|
+
const lb = loopback.info();
|
|
29
|
+
const overlay = {};
|
|
30
|
+
if (adapter && adapter.id) overlay.OC_CHANNEL_ADAPTER = adapter.id;
|
|
31
|
+
if (channelId) overlay.OC_CHANNEL_ID = String(channelId);
|
|
32
|
+
if (lb) {
|
|
33
|
+
overlay.OC_SEND_URL = lb.url;
|
|
34
|
+
overlay.OC_SEND_TOKEN = lb.token;
|
|
35
|
+
}
|
|
36
|
+
const tinfo = transcriptProjectInfo();
|
|
37
|
+
if (tinfo && tinfo.transcriptPath) overlay.OC_TRANSCRIPT_PATH = tinfo.transcriptPath;
|
|
38
|
+
return overlay;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function subprocessEnv() {
|
|
42
|
+
return { ...claudeSubprocessEnv(), ...chatEnvOverlay() };
|
|
43
|
+
}
|
|
23
44
|
|
|
24
45
|
function parseStreamEvents(data) {
|
|
25
46
|
const events = [];
|
|
@@ -52,8 +73,11 @@ function getActiveSessionKey(state = currentState()) {
|
|
|
52
73
|
function shouldAutoCompact(state = currentState(), opts = {}) {
|
|
53
74
|
if (opts.skipAutoCompact || opts.fresh || opts.continueSession || state.isCompacting) return false;
|
|
54
75
|
if (!state[getActiveSessionKey(state)]) return false;
|
|
55
|
-
const threshold = Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS :
|
|
56
|
-
|
|
76
|
+
const threshold = Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS : 380000;
|
|
77
|
+
if ((state.sessionUsage?.lastInputTokens || 0) < threshold) return false;
|
|
78
|
+
const minInterval = Number.isFinite(MIN_COMPACT_INTERVAL_MS) ? MIN_COMPACT_INTERVAL_MS : 1800000;
|
|
79
|
+
if (state.lastCompactedAt && (Date.now() - state.lastCompactedAt) < minInterval) return false;
|
|
80
|
+
return true;
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
function buildClaudeArgs(prompt, opts = {}) {
|
|
@@ -126,7 +150,7 @@ function preflightClaudeAuthMessage() {
|
|
|
126
150
|
const { execSync } = require("child_process");
|
|
127
151
|
const output = execSync(`"${CLAUDE_PATH}" auth status`, {
|
|
128
152
|
cwd: process.env.HOME || require("os").homedir(),
|
|
129
|
-
env:
|
|
153
|
+
env: subprocessEnv(),
|
|
130
154
|
encoding: "utf8",
|
|
131
155
|
timeout: 10000,
|
|
132
156
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -162,13 +186,20 @@ function claudeEmptyFailureMessage(code, stderrText = "") {
|
|
|
162
186
|
function compactSummaryPrompt() {
|
|
163
187
|
return [
|
|
164
188
|
"Summarize this conversation for a fresh compacted continuation.",
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
"-
|
|
169
|
-
"-
|
|
170
|
-
"-
|
|
171
|
-
"
|
|
189
|
+
"Write a brief that lets your future self resume work without re-asking the user.",
|
|
190
|
+
"",
|
|
191
|
+
"Include:",
|
|
192
|
+
"- Current user goal and any active side threads or sub-goals.",
|
|
193
|
+
"- Locked-in constraints, preferences, and decisions (with the WHY when non-obvious).",
|
|
194
|
+
"- Infrastructure / release facts: how things deploy, publish, or release; which CI handles what; required env or secrets; commands the user said NOT to run locally.",
|
|
195
|
+
"- Files and repos touched, current branch / commit / tag state, what is committed vs uncommitted, what is pushed vs local.",
|
|
196
|
+
"- Commands already run and their results — so you do not re-run completed setup and do not re-attempt failed steps.",
|
|
197
|
+
"- Open TODOs, blockers, and the exact next step.",
|
|
198
|
+
"- User pushback or corrections this session — your future self must not repeat the mistake. Quote the correction if short.",
|
|
199
|
+
"- Stable paths, IDs, PIDs, owner IDs, and reference URLs the work depends on.",
|
|
200
|
+
"",
|
|
201
|
+
"Do not include: secrets, raw tokens, full file dumps, or chat pleasantries.",
|
|
202
|
+
"Be concrete. Names of files, commands, flags, tags, and commits beat paraphrase. If a fact was load-bearing in this session, write it down verbatim.",
|
|
172
203
|
].join("\n");
|
|
173
204
|
}
|
|
174
205
|
|
|
@@ -178,6 +209,17 @@ function compactSeedPrompt(summary) {
|
|
|
178
209
|
"Treat the following summary as the prior conversation context. Do not repeat it back unless asked.",
|
|
179
210
|
"Continue from this state in future turns.",
|
|
180
211
|
"",
|
|
212
|
+
"Before telling the user you lack context on something they reference:",
|
|
213
|
+
"1. Check the summary below.",
|
|
214
|
+
"2. Search the project transcript with `open-claudia transcript-window <pattern>`.",
|
|
215
|
+
" It returns each hit with surrounding turns of context, capped per turn so it stays bounded.",
|
|
216
|
+
" Useful flags: --before N / --after N (default 2), --max-turns K (default 10), --regex.",
|
|
217
|
+
" Fall back to `grep -n -C 5 <pattern> <transcript-path>` (path in your system prompt under",
|
|
218
|
+
" 'Project Transcript Memory') only if the helper does not fit your search.",
|
|
219
|
+
"Only ask the user if both turn up nothing.",
|
|
220
|
+
"",
|
|
221
|
+
"If a fact in the summary contradicts current repo state (a file path, a command, a flag, a version), trust what you observe now and proceed without flagging it unless the user asks.",
|
|
222
|
+
"",
|
|
181
223
|
"Compacted summary:",
|
|
182
224
|
summary,
|
|
183
225
|
].join("\n");
|
|
@@ -218,7 +260,7 @@ async function runClaudeCapture(prompt, cwd, opts = {}) {
|
|
|
218
260
|
const args = buildClaudeArgs(prompt, { ...opts, skipAutoCompact: true });
|
|
219
261
|
const proc = spawn(getActiveBinary(), args, {
|
|
220
262
|
cwd,
|
|
221
|
-
env:
|
|
263
|
+
env: subprocessEnv(),
|
|
222
264
|
stdio: ["ignore", "pipe", "pipe"],
|
|
223
265
|
detached: process.platform !== "win32",
|
|
224
266
|
});
|
|
@@ -347,17 +389,6 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
347
389
|
return;
|
|
348
390
|
}
|
|
349
391
|
|
|
350
|
-
if (shouldAutoCompact(state, opts)) {
|
|
351
|
-
try {
|
|
352
|
-
await compactActiveSession(cwd, {
|
|
353
|
-
notify: true,
|
|
354
|
-
message: "Context is getting large, compacting first so this stays fast…",
|
|
355
|
-
});
|
|
356
|
-
} catch (e) {
|
|
357
|
-
await send(`Compaction failed: ${redactSensitive(e.message)}\nContinuing in the existing session.`, { replyTo: replyToMsgId });
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
392
|
appendProjectTranscript("user", prompt, {
|
|
362
393
|
sourceMessageId: replyToMsgId || null,
|
|
363
394
|
fresh: !!opts.fresh,
|
|
@@ -377,7 +408,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
377
408
|
const binaryPath = getActiveBinary();
|
|
378
409
|
const proc = spawn(binaryPath, args, {
|
|
379
410
|
cwd,
|
|
380
|
-
env:
|
|
411
|
+
env: subprocessEnv(),
|
|
381
412
|
stdio: ["ignore", "pipe", "pipe"],
|
|
382
413
|
detached: process.platform !== "win32",
|
|
383
414
|
});
|
package/core/system-prompt.js
CHANGED
|
@@ -45,7 +45,11 @@ ${soul}
|
|
|
45
45
|
${transcriptPointerNote(state)}
|
|
46
46
|
|
|
47
47
|
## Delivery
|
|
48
|
-
Reply normally in your final answer.
|
|
48
|
+
Reply normally in your final answer. To send a file, image, or voice clip back to the current chat, run the bot CLI from inside this task — channel context is already in the env:
|
|
49
|
+
- \`open-claudia send-file <path> [caption]\` — any document/binary
|
|
50
|
+
- \`open-claudia send-photo <path> [caption]\` — image with inline preview
|
|
51
|
+
- \`open-claudia send-voice <path>\` — ogg/opus voice note
|
|
52
|
+
Never print or embed bot tokens in prompts, commands, logs, or messages.
|
|
49
53
|
|
|
50
54
|
## Guidelines
|
|
51
55
|
- Keep responses concise — many users are on mobile.
|
package/package.json
CHANGED