@inetafrica/open-claudia 1.20.0 → 2.0.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/.env.example +15 -1
- package/CHANGELOG.md +15 -0
- package/Dockerfile +3 -0
- package/README.md +2 -1
- package/bot.js +109 -3440
- package/channels/kazee/adapter.js +375 -0
- package/channels/kazee/format.js +9 -0
- package/channels/kazee/socket.js +28 -0
- package/channels/telegram/adapter.js +296 -0
- package/channels/telegram/format.js +12 -0
- package/channels/types.js +91 -0
- package/core/access.js +147 -0
- package/core/actions.js +180 -0
- package/core/adapter-registry.js +113 -0
- package/core/auth-flow.js +433 -0
- package/core/channel-wizard.js +174 -0
- package/core/commands.js +73 -0
- package/core/config.js +197 -0
- package/core/context.js +44 -0
- package/core/cron.js +77 -0
- package/core/doctor.js +126 -0
- package/core/handlers.js +995 -0
- package/core/identity.js +87 -0
- package/core/io.js +59 -0
- package/core/kazee-probe.js +48 -0
- package/core/media.js +39 -0
- package/core/onboarding.js +97 -0
- package/core/process-tree.js +40 -0
- package/core/projects.js +43 -0
- package/core/redact.js +26 -0
- package/core/router.js +309 -0
- package/core/runner.js +683 -0
- package/core/state.js +261 -0
- package/core/system-prompt.js +66 -0
- package/core/transcripts.js +71 -0
- package/core/vault-store.js +9 -0
- package/docker-entrypoint.sh +21 -4
- package/package.json +6 -3
- package/project-transcripts.js +17 -12
- package/setup.js +44 -3
package/core/identity.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Per-channel → canonical user mapping. The single source of truth for
|
|
2
|
+
// "who is this message from" across Telegram, Kazee, and any future
|
|
3
|
+
// transport. Default canonical is `<transport>:<channelId>`; the user
|
|
4
|
+
// can override with /link <email-or-id> to merge state across channels.
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const { IDENTITIES_FILE } = require("./config");
|
|
8
|
+
|
|
9
|
+
function normalizeCanonicalUserId(value) {
|
|
10
|
+
return String(value || "").trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function channelKey(transport, channelId) {
|
|
14
|
+
return `${String(transport || "").trim().toLowerCase()}:${String(channelId || "").trim()}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function defaultCanonicalForChannel(transport, channelId) {
|
|
18
|
+
return channelKey(transport, channelId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadIdentities() {
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(fs.readFileSync(IDENTITIES_FILE, "utf-8"));
|
|
24
|
+
return {
|
|
25
|
+
channels: raw && typeof raw.channels === "object" ? raw.channels : {},
|
|
26
|
+
preferred: raw && typeof raw.preferred === "object" ? raw.preferred : {},
|
|
27
|
+
};
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return { channels: {}, preferred: {} };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const identities = loadIdentities();
|
|
34
|
+
|
|
35
|
+
function saveIdentities() {
|
|
36
|
+
try { fs.writeFileSync(IDENTITIES_FILE, JSON.stringify(identities, null, 2)); } catch (e) {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function canonicalForChannel(transport, channelId) {
|
|
40
|
+
const key = channelKey(transport, channelId);
|
|
41
|
+
return normalizeCanonicalUserId(identities.channels[key]) || defaultCanonicalForChannel(transport, channelId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function canonicalForTelegram(chatId) {
|
|
45
|
+
return canonicalForChannel("telegram", chatId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function canonicalForStoredUserKey(key) {
|
|
49
|
+
const id = String(key);
|
|
50
|
+
if (id.includes(":") || id.includes("@")) return normalizeCanonicalUserId(id);
|
|
51
|
+
// Legacy keys without a transport prefix are Telegram chat ids.
|
|
52
|
+
return canonicalForTelegram(id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Set or update a channel→canonical link. Returns metadata so the caller
|
|
56
|
+
// can run state migration via core/state.js (cyclic-import-safe pattern).
|
|
57
|
+
function setIdentityMapping(transport, channelId, canonicalUserId) {
|
|
58
|
+
const key = channelKey(transport, channelId);
|
|
59
|
+
const userId = normalizeCanonicalUserId(canonicalUserId);
|
|
60
|
+
if (!userId) throw new Error("Canonical user id is required.");
|
|
61
|
+
const hadExplicitMapping = Object.prototype.hasOwnProperty.call(identities.channels, key);
|
|
62
|
+
const previousUserId = canonicalForChannel(transport, channelId);
|
|
63
|
+
const defaultUserId = defaultCanonicalForChannel(transport, channelId);
|
|
64
|
+
identities.channels[key] = userId;
|
|
65
|
+
identities.preferred[userId] = { transport: String(transport).toLowerCase(), channelId: String(channelId) };
|
|
66
|
+
saveIdentities();
|
|
67
|
+
const shouldMigrate = !hadExplicitMapping || previousUserId === defaultUserId;
|
|
68
|
+
return {
|
|
69
|
+
key,
|
|
70
|
+
previousUserId,
|
|
71
|
+
userId,
|
|
72
|
+
shouldMigrate,
|
|
73
|
+
migrated: shouldMigrate && previousUserId !== userId,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
identities,
|
|
79
|
+
normalizeCanonicalUserId,
|
|
80
|
+
channelKey,
|
|
81
|
+
defaultCanonicalForChannel,
|
|
82
|
+
canonicalForChannel,
|
|
83
|
+
canonicalForTelegram,
|
|
84
|
+
canonicalForStoredUserKey,
|
|
85
|
+
setIdentityMapping,
|
|
86
|
+
saveIdentities,
|
|
87
|
+
};
|
package/core/io.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Channel-agnostic IO. Every send/edit/delete in core code goes through
|
|
2
|
+
// here so that the right adapter is invoked for the in-flight message
|
|
3
|
+
// without each handler having to know about transports.
|
|
4
|
+
|
|
5
|
+
const { currentAdapter, currentChannelId } = require("./context");
|
|
6
|
+
|
|
7
|
+
async function send(text, opts = {}) {
|
|
8
|
+
const adapter = currentAdapter();
|
|
9
|
+
const channelId = opts.channelId || currentChannelId();
|
|
10
|
+
if (!adapter || !channelId) {
|
|
11
|
+
console.error("send() called outside chat context");
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return adapter.send(channelId, text, opts);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function editMessage(messageId, text, opts = {}) {
|
|
18
|
+
const adapter = currentAdapter();
|
|
19
|
+
const channelId = opts.channelId || currentChannelId();
|
|
20
|
+
if (!adapter || !channelId) return;
|
|
21
|
+
return adapter.edit(channelId, messageId, text, opts);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function deleteMessage(messageId) {
|
|
25
|
+
const adapter = currentAdapter();
|
|
26
|
+
const channelId = currentChannelId();
|
|
27
|
+
if (!adapter || !channelId) return;
|
|
28
|
+
return adapter.delete(channelId, messageId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function sendVoice(oggPath) {
|
|
32
|
+
const adapter = currentAdapter();
|
|
33
|
+
const channelId = currentChannelId();
|
|
34
|
+
if (!adapter || !channelId) return false;
|
|
35
|
+
return adapter.sendVoice(channelId, oggPath);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function sendFile(filePath, caption) {
|
|
39
|
+
const adapter = currentAdapter();
|
|
40
|
+
const channelId = currentChannelId();
|
|
41
|
+
if (!adapter || !channelId) return false;
|
|
42
|
+
return adapter.sendFile(channelId, filePath, caption);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function typing() {
|
|
46
|
+
const adapter = currentAdapter();
|
|
47
|
+
const channelId = currentChannelId();
|
|
48
|
+
if (!adapter || !channelId) return;
|
|
49
|
+
return adapter.typing(channelId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitMessage(text, maxLen = 4000) {
|
|
53
|
+
if (text.length <= maxLen) return [text];
|
|
54
|
+
const chunks = [];
|
|
55
|
+
while (text.length > 0) { chunks.push(text.slice(0, maxLen)); text = text.slice(maxLen); }
|
|
56
|
+
return chunks;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { send, editMessage, deleteMessage, sendVoice, sendFile, typing, splitMessage };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Standalone Kazee GET /me probe. Lives outside the adapter so setup.js
|
|
2
|
+
// can use it before .env exists (channel-wizard, which is the other
|
|
3
|
+
// consumer, imports it indirectly).
|
|
4
|
+
|
|
5
|
+
const https = require("https");
|
|
6
|
+
const http = require("http");
|
|
7
|
+
const { URL } = require("url");
|
|
8
|
+
|
|
9
|
+
function validateKazee({ url, token }) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let u;
|
|
12
|
+
try { u = new URL(url.replace(/\/$/, "") + "/me"); }
|
|
13
|
+
catch (e) { return resolve({ ok: false, error: `Bad URL: ${e.message}` }); }
|
|
14
|
+
const lib = u.protocol === "https:" ? https : http;
|
|
15
|
+
const req = lib.request({
|
|
16
|
+
method: "GET",
|
|
17
|
+
hostname: u.hostname,
|
|
18
|
+
port: u.port || (u.protocol === "https:" ? 443 : 80),
|
|
19
|
+
path: u.pathname + (u.search || ""),
|
|
20
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
21
|
+
}, (res) => {
|
|
22
|
+
let chunks = "";
|
|
23
|
+
res.on("data", (c) => { chunks += c; });
|
|
24
|
+
res.on("end", () => {
|
|
25
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
26
|
+
try {
|
|
27
|
+
const body = chunks ? JSON.parse(chunks) : {};
|
|
28
|
+
const botUserId = body?.user?._id || body?.user?.id || body?._id || "";
|
|
29
|
+
resolve({ ok: true, botUserId });
|
|
30
|
+
} catch (e) {
|
|
31
|
+
resolve({ ok: true, botUserId: "" });
|
|
32
|
+
}
|
|
33
|
+
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
|
34
|
+
resolve({ ok: false, error: "Token rejected (401/403). Check that it's a valid Kazee bot token." });
|
|
35
|
+
} else if (res.statusCode === 404) {
|
|
36
|
+
resolve({ ok: false, error: "GET /me returned 404. Check that the URL points at the Kazee bot API root." });
|
|
37
|
+
} else {
|
|
38
|
+
resolve({ ok: false, error: `GET /me → HTTP ${res.statusCode}` });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
req.on("error", (e) => resolve({ ok: false, error: e.message }));
|
|
43
|
+
req.setTimeout(15000, () => req.destroy(new Error("Timed out after 15s")));
|
|
44
|
+
req.end();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { validateKazee };
|
package/core/media.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Voice ↔ text helpers. Transport-neutral: works off local file paths
|
|
2
|
+
// produced by adapter.downloadMedia().
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { execSync } = require("child_process");
|
|
7
|
+
const { WHISPER_CLI, WHISPER_MODEL, FFMPEG, TEMP_DIR } = require("./config");
|
|
8
|
+
|
|
9
|
+
const TTS_CMD = process.platform === "darwin" ? "say" : null;
|
|
10
|
+
|
|
11
|
+
function transcribeAudio(oggPath) {
|
|
12
|
+
if (!WHISPER_CLI || !FFMPEG) return null;
|
|
13
|
+
const wavPath = oggPath.replace(/\.[^.]+$/, ".wav");
|
|
14
|
+
execSync(`"${FFMPEG}" -i "${oggPath}" -ar 16000 -ac 1 -y "${wavPath}" 2>/dev/null`);
|
|
15
|
+
const output = execSync(`"${WHISPER_CLI}" -m "${WHISPER_MODEL}" --no-timestamps -f "${wavPath}" 2>/dev/null`, { encoding: "utf-8" });
|
|
16
|
+
try { fs.unlinkSync(wavPath); } catch (e) {}
|
|
17
|
+
return output.split("\n")
|
|
18
|
+
.filter((l) => l.trim() && !l.startsWith("whisper_") && !l.startsWith("ggml_") && !l.startsWith("load_"))
|
|
19
|
+
.join(" ").trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function textToVoice(text) {
|
|
23
|
+
if (!TTS_CMD || !FFMPEG) return null;
|
|
24
|
+
try {
|
|
25
|
+
const clean = text.replace(/[*_`#>\[\]()]/g, "").replace(/\n{2,}/g, ". ").replace(/\n/g, " ").trim();
|
|
26
|
+
if (!clean) return null;
|
|
27
|
+
const aiffPath = path.join(TEMP_DIR, `tts-${Date.now()}.aiff`);
|
|
28
|
+
const oggPath = aiffPath.replace(".aiff", ".ogg");
|
|
29
|
+
execSync(`${TTS_CMD} ${JSON.stringify(clean)} -o "${aiffPath}"`, { timeout: 30000 });
|
|
30
|
+
execSync(`"${FFMPEG}" -i "${aiffPath}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 30000 });
|
|
31
|
+
try { fs.unlinkSync(aiffPath); } catch (e) {}
|
|
32
|
+
return oggPath;
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error("TTS error:", e.message);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { transcribeAudio, textToVoice, TTS_CMD };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// First-run flow. Owner-only in practice — once ONBOARDED=true is
|
|
2
|
+
// written to .env, the gate stays closed for everyone else.
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const { config, saveEnvKey, SOUL_FILE } = require("./config");
|
|
6
|
+
const { currentState } = require("./state");
|
|
7
|
+
const { send } = require("./io");
|
|
8
|
+
|
|
9
|
+
function isOnboarded() {
|
|
10
|
+
return config.ONBOARDED === "true";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function startOnboarding() {
|
|
14
|
+
const state = currentState();
|
|
15
|
+
state.onboardingStep = "name";
|
|
16
|
+
state.onboardingData = {};
|
|
17
|
+
await send("Welcome! Let's set me up.\n\nWhat should I call you?");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function handleOnboarding(msg) {
|
|
21
|
+
const state = currentState();
|
|
22
|
+
const text = msg.text;
|
|
23
|
+
if (state.onboardingStep === "name") {
|
|
24
|
+
state.onboardingData.name = text;
|
|
25
|
+
state.onboardingStep = "role";
|
|
26
|
+
await send(`Nice to meet you, ${text}!\n\nWhat's your role? (e.g., "full-stack developer", "startup founder", "student")`);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (state.onboardingStep === "role") {
|
|
30
|
+
state.onboardingData.role = text;
|
|
31
|
+
state.onboardingStep = "style";
|
|
32
|
+
await send("How should I communicate?\n\nPick a style:", {
|
|
33
|
+
keyboard: {
|
|
34
|
+
inline_keyboard: [
|
|
35
|
+
[
|
|
36
|
+
{ text: "Concise & technical", callback_data: "ob:concise" },
|
|
37
|
+
{ text: "Detailed & educational", callback_data: "ob:detailed" },
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
{ text: "Casual & friendly", callback_data: "ob:casual" },
|
|
41
|
+
{ text: "Minimal (just code)", callback_data: "ob:minimal" },
|
|
42
|
+
],
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function finishOnboarding(style) {
|
|
52
|
+
const state = currentState();
|
|
53
|
+
state.onboardingData.style = style;
|
|
54
|
+
state.onboardingStep = null;
|
|
55
|
+
const data = state.onboardingData;
|
|
56
|
+
|
|
57
|
+
const styleDescriptions = {
|
|
58
|
+
concise: "Direct, concise, technical. No fluff. Lead with the answer.",
|
|
59
|
+
detailed: "Thorough and educational. Explain the why, not just the what.",
|
|
60
|
+
casual: "Casual and friendly. Like chatting with a smart friend who codes.",
|
|
61
|
+
minimal: "Minimal output. Just code and brief explanations. No chatter.",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const soul = `# Soul
|
|
65
|
+
|
|
66
|
+
You are ${data.name}'s personal AI coding assistant, running 24/7 across chat channels.
|
|
67
|
+
|
|
68
|
+
## Identity
|
|
69
|
+
- Tone: ${styleDescriptions[style]}
|
|
70
|
+
- Platform: chat (mobile-first, keep messages short)
|
|
71
|
+
|
|
72
|
+
## About ${data.name}
|
|
73
|
+
- Role: ${data.role}
|
|
74
|
+
|
|
75
|
+
## Working Style
|
|
76
|
+
- Prefer action over discussion. Do the thing, then explain.
|
|
77
|
+
- Send files for long output instead of text walls.
|
|
78
|
+
- Proactively flag issues and suggest improvements.
|
|
79
|
+
|
|
80
|
+
## Capabilities
|
|
81
|
+
- Write, edit, and refactor code across all projects
|
|
82
|
+
- Run commands, tests, builds
|
|
83
|
+
- Send files, images, code snippets directly via chat
|
|
84
|
+
- Run scheduled checks (cron jobs) and report results
|
|
85
|
+
- Store and use credentials from the encrypted vault
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
fs.writeFileSync(SOUL_FILE, soul);
|
|
89
|
+
saveEnvKey("ONBOARDED", "true");
|
|
90
|
+
config.ONBOARDED = "true";
|
|
91
|
+
|
|
92
|
+
send(`All set, ${data.name}! I'm configured and ready.\n\nTap /session to pick a project and start working.`, {
|
|
93
|
+
keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { isOnboarded, startOnboarding, handleOnboarding, finishOnboarding };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Background tools (e.g. Bash run_in_background, /stop on a dev server)
|
|
2
|
+
// often spawn into their own session/group, so a single PGID kill on the
|
|
3
|
+
// Claude CLI doesn't reach them. We walk the descendant tree explicitly
|
|
4
|
+
// and signal each PID. Without this, runaway poll-loops survive
|
|
5
|
+
// indefinitely after the parent CLI is SIGTERMed.
|
|
6
|
+
|
|
7
|
+
const { execSync } = require("child_process");
|
|
8
|
+
|
|
9
|
+
function descendantPids(rootPid) {
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
const queue = [Number(rootPid)];
|
|
12
|
+
while (queue.length) {
|
|
13
|
+
const pid = queue.shift();
|
|
14
|
+
if (!pid || seen.has(pid)) continue;
|
|
15
|
+
seen.add(pid);
|
|
16
|
+
try {
|
|
17
|
+
const out = execSync(`pgrep -P ${pid}`, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
18
|
+
for (const line of out.split("\n")) {
|
|
19
|
+
const child = Number(line.trim());
|
|
20
|
+
if (child) queue.push(child);
|
|
21
|
+
}
|
|
22
|
+
} catch (e) {
|
|
23
|
+
// pgrep exits 1 when no children — ignore.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
seen.delete(Number(rootPid));
|
|
27
|
+
return [...seen];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function killProcessTree(rootPid, signal = "SIGTERM") {
|
|
31
|
+
if (!rootPid) return;
|
|
32
|
+
const descendants = descendantPids(rootPid);
|
|
33
|
+
try { process.kill(-rootPid, signal); } catch (e) {}
|
|
34
|
+
try { process.kill(rootPid, signal); } catch (e) {}
|
|
35
|
+
for (const pid of descendants) {
|
|
36
|
+
try { process.kill(pid, signal); } catch (e) {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { descendantPids, killProcessTree };
|
package/core/projects.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Workspace project utilities. Reads filesystem; no IO dependencies.
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { WORKSPACE } = require("./config");
|
|
6
|
+
|
|
7
|
+
function listProjects() {
|
|
8
|
+
try {
|
|
9
|
+
return fs.readdirSync(WORKSPACE, { withFileTypes: true })
|
|
10
|
+
.filter((d) => d.isDirectory())
|
|
11
|
+
.map((d) => d.name)
|
|
12
|
+
.filter((n) => !n.startsWith("."));
|
|
13
|
+
} catch (e) { return []; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function findProject(query) {
|
|
17
|
+
const projects = listProjects();
|
|
18
|
+
const q = query.toLowerCase();
|
|
19
|
+
const exact = projects.find((p) => p.toLowerCase() === q);
|
|
20
|
+
if (exact) return exact;
|
|
21
|
+
const startsWith = projects.filter((p) => p.toLowerCase().startsWith(q));
|
|
22
|
+
if (startsWith.length === 1) return startsWith[0];
|
|
23
|
+
const contains = projects.filter((p) => p.toLowerCase().includes(q));
|
|
24
|
+
if (contains.length === 1) return contains[0];
|
|
25
|
+
return contains.length > 0 ? contains : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function projectKeyboard() {
|
|
29
|
+
const projects = listProjects();
|
|
30
|
+
const rows = [[{ text: "\u{1F4C1} Workspace (root)", callback_data: "s:__workspace__" }]];
|
|
31
|
+
for (let i = 0; i < projects.length; i += 2) {
|
|
32
|
+
const row = [{ text: projects[i], callback_data: `s:${projects[i]}` }];
|
|
33
|
+
if (projects[i + 1]) row.push({ text: projects[i + 1], callback_data: `s:${projects[i + 1]}` });
|
|
34
|
+
rows.push(row);
|
|
35
|
+
}
|
|
36
|
+
return { inline_keyboard: rows };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function workspacePath(name) {
|
|
40
|
+
return name === "__workspace__" ? WORKSPACE : path.join(WORKSPACE, name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { listProjects, findProject, projectKeyboard, workspacePath };
|
package/core/redact.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Secret-redaction + terminal-control stripping. Used everywhere we ship
|
|
2
|
+
// CLI output (stderr, stdout, transcripts) back to a chat surface.
|
|
3
|
+
|
|
4
|
+
function redactSensitive(value) {
|
|
5
|
+
return String(value || "")
|
|
6
|
+
.replace(/sk-ant-[A-Za-z0-9._-]+/g, "[REDACTED_TOKEN]")
|
|
7
|
+
.replace(/sk-proj-[A-Za-z0-9._-]+/g, "[REDACTED_OPENAI_KEY]")
|
|
8
|
+
.replace(/sk-[A-Za-z0-9._-]{20,}/g, "[REDACTED_OPENAI_KEY]")
|
|
9
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
|
|
10
|
+
.replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
|
|
11
|
+
.replace(/(OPENAI_API_KEY\s*=\s*)\S+/gi, "$1[REDACTED_OPENAI_KEY]")
|
|
12
|
+
.replace(/([?&](?:token|access_token|refresh_token|api_key)=)[^\s&]+/gi, "$1[REDACTED]");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stripTerminalControls(value) {
|
|
16
|
+
return String(value || "")
|
|
17
|
+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
|
|
18
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
19
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function extractUrls(text) {
|
|
23
|
+
return [...stripTerminalControls(text).matchAll(/https?:\/\/[^\s)]+/g)].map((m) => m[0]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { redactSensitive, stripTerminalControls, extractUrls };
|