causly-server 0.0.0-stage → 1.5.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 +120 -0
- package/CHANGELOG.md +121 -0
- package/LICENSE +21 -0
- package/README.md +205 -3
- package/bin/causly.js +192 -0
- package/index.js +229 -0
- package/package.json +59 -6
- package/packs/calendar.js +141 -0
- package/packs/command.js +22 -0
- package/packs/database.js +95 -0
- package/packs/directory.js +63 -0
- package/packs/discord.js +63 -0
- package/packs/docker.js +202 -0
- package/packs/files.js +106 -0
- package/packs/git.js +211 -0
- package/packs/github.js +321 -0
- package/packs/gmail.js +98 -0
- package/packs/leads.js +46 -0
- package/packs/linear.js +104 -0
- package/packs/loader.js +247 -0
- package/packs/manifest.json +1152 -0
- package/packs/meetings.js +141 -0
- package/packs/notion.js +185 -0
- package/packs/pipeline.js +151 -0
- package/packs/project.js +70 -0
- package/packs/registry.js +149 -0
- package/packs/reminders.js +57 -0
- package/packs/research.js +34 -0
- package/packs/secrets.js +77 -0
- package/packs/sentry.js +97 -0
- package/packs/seo.js +70 -0
- package/packs/slack.js +95 -0
- package/packs/stripe.js +127 -0
- package/packs/supabase.js +73 -0
- package/packs/telegram.js +70 -0
- package/packs/terraform.js +247 -0
- package/packs/vercel.js +114 -0
- package/packs/workflow.js +84 -0
- package/prompts/cofounder.js +66 -0
- package/prompts/playbooks.js +75 -0
- package/tools/backupOps.js +87 -0
- package/tools/boundaryAttest.js +184 -0
- package/tools/calendarOps.js +563 -0
- package/tools/cliOps.js +233 -0
- package/tools/commandOps.js +198 -0
- package/tools/dbOps.js +117 -0
- package/tools/directoryOps.js +96 -0
- package/tools/discordOps.js +133 -0
- package/tools/dockerOps.js +259 -0
- package/tools/envFile.js +89 -0
- package/tools/envLoader.js +85 -0
- package/tools/fileOps.js +125 -0
- package/tools/gitOps.js +170 -0
- package/tools/githubOps.js +357 -0
- package/tools/gmailOps.js +221 -0
- package/tools/googleAuth.js +51 -0
- package/tools/googleLogin.js +230 -0
- package/tools/leadOps.js +447 -0
- package/tools/linearOps.js +304 -0
- package/tools/logger.js +47 -0
- package/tools/meetingOps.js +697 -0
- package/tools/notionOps.js +215 -0
- package/tools/paths.js +36 -0
- package/tools/pipelineOps.js +511 -0
- package/tools/projectOps.js +192 -0
- package/tools/reminderAlerts.js +48 -0
- package/tools/reminderOps.js +129 -0
- package/tools/scheduler.js +46 -0
- package/tools/secretsOps.js +133 -0
- package/tools/security.js +488 -0
- package/tools/sentryOps.js +130 -0
- package/tools/seoOps.js +561 -0
- package/tools/services.js +295 -0
- package/tools/setupOps.js +245 -0
- package/tools/slackOps.js +151 -0
- package/tools/statusOps.js +104 -0
- package/tools/store.js +111 -0
- package/tools/stripeOps.js +299 -0
- package/tools/supabaseOps.js +94 -0
- package/tools/telegramOps.js +153 -0
- package/tools/terminal.js +124 -0
- package/tools/terraformOps.js +171 -0
- package/tools/toolWrapper.js +60 -0
- package/tools/toolsetOps.js +123 -0
- package/tools/transcribeOps.js +221 -0
- package/tools/updateCheck.js +58 -0
- package/tools/vercelOps.js +198 -0
- package/tools/verifyBoundaryAttest.js +25 -0
- package/tools/webOps.js +474 -0
- package/tools/workflowOps.js +371 -0
- package/tools/workspace.js +85 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// `causly-server toolsets`: choose which packs load at startup in the user's editors.
|
|
2
|
+
// Clients like Claude Desktop do not refresh their tool list mid-chat, so what is
|
|
3
|
+
// switched on here is what the agent can use from the first message.
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import { PACKS } from "../packs/registry.js";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_TOOLSETS,
|
|
8
|
+
SERVER_NAME,
|
|
9
|
+
clientConfigPath,
|
|
10
|
+
pickClients,
|
|
11
|
+
readJson,
|
|
12
|
+
backup,
|
|
13
|
+
} from "./cliOps.js";
|
|
14
|
+
|
|
15
|
+
const CLIENT_LABEL = { claude: "Claude Desktop", cursor: "Cursor", windsurf: "Windsurf" };
|
|
16
|
+
const ACTIONS = ["list", "add", "remove", "reset"];
|
|
17
|
+
|
|
18
|
+
const parse = (v) =>
|
|
19
|
+
String(v || "")
|
|
20
|
+
.split(",")
|
|
21
|
+
.map((s) => s.trim().toLowerCase())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
|
|
24
|
+
const coreNames = () => Object.keys(PACKS).filter((n) => PACKS[n].core);
|
|
25
|
+
const optionalNames = () => Object.keys(PACKS).filter((n) => !PACKS[n].core);
|
|
26
|
+
|
|
27
|
+
function toolCounts() {
|
|
28
|
+
try {
|
|
29
|
+
const m = JSON.parse(fs.readFileSync(new URL("../packs/manifest.json", import.meta.url), "utf8"));
|
|
30
|
+
return Object.fromEntries(Object.entries(m.packs || {}).map(([n, p]) => [n, p.tools?.length ?? null]));
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** "all" is expanded so a single pack can be removed from it. */
|
|
37
|
+
const expand = (list) => (list.includes("all") ? [...new Set([...list.filter((n) => n !== "all"), ...optionalNames()])] : list);
|
|
38
|
+
|
|
39
|
+
export function toolsetRows(list) {
|
|
40
|
+
const on = new Set(expand(list));
|
|
41
|
+
const counts = toolCounts();
|
|
42
|
+
return Object.entries(PACKS).map(([name, pack]) => ({
|
|
43
|
+
name,
|
|
44
|
+
description: pack.description,
|
|
45
|
+
core: Boolean(pack.core),
|
|
46
|
+
on: Boolean(pack.core) || on.has(name),
|
|
47
|
+
tools: counts[name] ?? null,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatToolsets(rows) {
|
|
52
|
+
const width = Math.max(...rows.map((r) => r.name.length));
|
|
53
|
+
const line = (r) =>
|
|
54
|
+
` ${r.on ? "on " : "off"} ${r.name.padEnd(width)} ${String(r.tools ?? "?").padStart(2)} tools ${r.description}`;
|
|
55
|
+
const on = rows.filter((r) => r.on);
|
|
56
|
+
const off = rows.filter((r) => !r.on);
|
|
57
|
+
const out = [`Loaded at startup (${on.length}):`, ...on.map(line)];
|
|
58
|
+
if (off.length) out.push("", `Not loaded (${off.length}); the agent can still load these in clients that refresh their tool list:`, ...off.map(line));
|
|
59
|
+
out.push("", "Change with: causly-server toolsets add <name,...> | remove <name,...> | reset");
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeToolsets(file, list) {
|
|
64
|
+
const { config } = readJson(file);
|
|
65
|
+
const entry = config.mcpServers[SERVER_NAME];
|
|
66
|
+
const env = { ...(entry.env || {}) };
|
|
67
|
+
if (list.length) env.CAUSLY_TOOLSETS = list.join(",");
|
|
68
|
+
else delete env.CAUSLY_TOOLSETS;
|
|
69
|
+
if (Object.keys(env).length) entry.env = env;
|
|
70
|
+
else delete entry.env;
|
|
71
|
+
backup(file);
|
|
72
|
+
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function runToolsets({ action = "list", names = [], client, platform, home, env } = {}) {
|
|
76
|
+
if (!ACTIONS.includes(action)) throw new Error(`Unknown toolsets command "${action}". Use: ${ACTIONS.join(", ")}.`);
|
|
77
|
+
const where = { platform, home, env };
|
|
78
|
+
const targets = pickClients(client || "all", where)
|
|
79
|
+
.map((c) => ({ client: c, file: clientConfigPath(c, where) }))
|
|
80
|
+
.filter((t) => {
|
|
81
|
+
const { config } = readJson(t.file);
|
|
82
|
+
return config.mcpServers && SERVER_NAME in config.mcpServers;
|
|
83
|
+
});
|
|
84
|
+
if (!targets.length) throw new Error('causly-server is not set up in any editor yet. Run "causly-server init" first.');
|
|
85
|
+
|
|
86
|
+
const requested = [...new Set(names.map((n) => String(n).trim().toLowerCase()).filter(Boolean))];
|
|
87
|
+
const current = (t) => parse(readJson(t.file).config.mcpServers[SERVER_NAME].env?.CAUSLY_TOOLSETS);
|
|
88
|
+
const notes = [];
|
|
89
|
+
let changed = false;
|
|
90
|
+
|
|
91
|
+
if (action === "add" || action === "remove") {
|
|
92
|
+
if (!requested.length) throw new Error(`Name at least one toolset, for example: causly-server toolsets ${action} github`);
|
|
93
|
+
const unknown = requested.filter((n) => !PACKS[n] && n !== "all");
|
|
94
|
+
if (unknown.length) throw new Error(`Unknown toolset: ${unknown.join(", ")}. Available: ${optionalNames().join(", ")}.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const t of targets) {
|
|
98
|
+
const before = current(t);
|
|
99
|
+
let after = before;
|
|
100
|
+
if (action === "reset") after = parse(DEFAULT_TOOLSETS);
|
|
101
|
+
else if (action === "add") after = [...new Set([...expand(before), ...requested.filter((n) => !PACKS[n]?.core)])];
|
|
102
|
+
else if (action === "remove") after = expand(before).filter((n) => !requested.includes(n));
|
|
103
|
+
if (action === "add" && requested.includes("all")) after = ["all"];
|
|
104
|
+
if (action !== "list" && after.join(",") !== before.join(",")) {
|
|
105
|
+
writeToolsets(t.file, after);
|
|
106
|
+
changed = true;
|
|
107
|
+
}
|
|
108
|
+
t.after = after;
|
|
109
|
+
}
|
|
110
|
+
for (const n of requested) if (PACKS[n]?.core) notes.push(`${n} is a core toolset and is always on.`);
|
|
111
|
+
|
|
112
|
+
const rows = toolsetRows(targets[0].after ?? current(targets[0]));
|
|
113
|
+
const lines = formatToolsets(rows);
|
|
114
|
+
if (action !== "list") {
|
|
115
|
+
const labels = targets.map((t) => CLIENT_LABEL[t.client]).join(" and ");
|
|
116
|
+
lines.unshift(
|
|
117
|
+
changed ? `Saved for ${labels}. These packs are in the tool list from the next restart; until then the agent can already use them through use_tool.` : "Nothing changed.",
|
|
118
|
+
...notes,
|
|
119
|
+
""
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return { changed, clients: targets.map((t) => t.client), rows, lines };
|
|
123
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Speech to text for meeting recordings, plus reading transcript files.
|
|
2
|
+
//
|
|
3
|
+
// Engine: Groq's hosted Whisper (OpenAI-compatible API). The audio file is sent to Groq,
|
|
4
|
+
// so callers must get the user's consent first (meeting_transcribe requires confirm: true).
|
|
5
|
+
// Whisper does not tell speakers apart, so the transcript has timestamps but no names.
|
|
6
|
+
//
|
|
7
|
+
// Credentials: GROQ_API_KEY (free key at console.groq.com/keys).
|
|
8
|
+
// Optional: GROQ_WHISPER_MODEL (default whisper-large-v3-turbo; whisper-large-v3 is a bit
|
|
9
|
+
// more accurate), GROQ_MAX_FILE_MB (25 on the free tier, 100 on the dev tier).
|
|
10
|
+
//
|
|
11
|
+
// Files over the limit, or in a format Groq does not take, are converted with ffmpeg when it
|
|
12
|
+
// is installed (mono 16 kHz Opus at 24 kbps: about 11 MB per hour). Without ffmpeg the error
|
|
13
|
+
// says what to do.
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import os from "os";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import crypto from "crypto";
|
|
18
|
+
import { execFile } from "child_process";
|
|
19
|
+
|
|
20
|
+
const GROQ_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
|
|
21
|
+
const DEFAULT_MODEL = "whisper-large-v3-turbo";
|
|
22
|
+
export const AUDIO_EXTS = ["flac", "mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm"];
|
|
23
|
+
export const TRANSCRIPT_EXTS = [".txt", ".md", ".vtt", ".srt"];
|
|
24
|
+
const MAX_TRANSCRIPT_FILE = 5 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- formatting
|
|
27
|
+
|
|
28
|
+
export function formatClock(seconds) {
|
|
29
|
+
const s = Math.max(0, Math.floor(Number(seconds) || 0));
|
|
30
|
+
const h = Math.floor(s / 3600);
|
|
31
|
+
const m = Math.floor((s % 3600) / 60);
|
|
32
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
33
|
+
return h ? `${h}:${pad(m)}:${pad(s % 60)}` : `${pad(m)}:${pad(s % 60)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whisper segments are sentence-sized; group them into lines of about `block` seconds: "[01:30] text". */
|
|
37
|
+
export function formatSegments(segments, fallbackText = "", block = 30) {
|
|
38
|
+
if (!Array.isArray(segments) || !segments.length) return String(fallbackText).trim();
|
|
39
|
+
const lines = [];
|
|
40
|
+
let start = null;
|
|
41
|
+
let parts = [];
|
|
42
|
+
const flush = () => {
|
|
43
|
+
if (parts.length) lines.push(`[${formatClock(start)}] ${parts.join(" ").replace(/\s+/g, " ").trim()}`);
|
|
44
|
+
parts = [];
|
|
45
|
+
start = null;
|
|
46
|
+
};
|
|
47
|
+
for (const seg of segments) {
|
|
48
|
+
const text = String(seg.text ?? "").trim();
|
|
49
|
+
if (!text) continue;
|
|
50
|
+
if (start !== null && seg.start - start >= block) flush();
|
|
51
|
+
if (start === null) start = seg.start ?? 0;
|
|
52
|
+
parts.push(text);
|
|
53
|
+
}
|
|
54
|
+
flush();
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cueTime = (s) => {
|
|
59
|
+
const m = /(?:(\d+):)?(\d{1,2}):(\d{2})[.,]\d+/.exec(s);
|
|
60
|
+
return m ? Number(m[1] || 0) * 3600 + Number(m[2]) * 60 + Number(m[3]) : 0;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Zoom, Meet, Teams and most tools export .vtt or .srt; turn the cues into "[mm:ss] Speaker: text" lines. */
|
|
64
|
+
export function parseSubtitles(raw) {
|
|
65
|
+
const blocks = String(raw).replace(/\r/g, "").replace(/^/, "").split(/\n{2,}/);
|
|
66
|
+
const lines = [];
|
|
67
|
+
for (const block of blocks) {
|
|
68
|
+
const rows = block.split("\n").map((r) => r.trim()).filter(Boolean);
|
|
69
|
+
const at = rows.findIndex((r) => r.includes("-->"));
|
|
70
|
+
if (at === -1) continue; // WEBVTT header, NOTE blocks, style blocks
|
|
71
|
+
const text = rows
|
|
72
|
+
.slice(at + 1)
|
|
73
|
+
.join(" ")
|
|
74
|
+
.replace(/<v\s+([^>]+)>/gi, "$1: ")
|
|
75
|
+
.replace(/<[^>]+>/g, "")
|
|
76
|
+
.replace(/\s+/g, " ")
|
|
77
|
+
.trim();
|
|
78
|
+
if (text) lines.push(`[${formatClock(cueTime(rows[at]))}] ${text}`);
|
|
79
|
+
}
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Read a .txt, .md, .vtt or .srt transcript. */
|
|
84
|
+
export function parseTranscriptFile(file) {
|
|
85
|
+
const ext = path.extname(String(file ?? "")).toLowerCase();
|
|
86
|
+
if (!TRANSCRIPT_EXTS.includes(ext)) throw new Error(`Transcript files must be ${TRANSCRIPT_EXTS.join(", ")} (got "${ext || "no extension"}"). For audio use meeting_transcribe.`);
|
|
87
|
+
let stat;
|
|
88
|
+
try {
|
|
89
|
+
stat = fs.statSync(file);
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error(`Cannot find the transcript file: ${path.basename(file)}.`);
|
|
92
|
+
}
|
|
93
|
+
if (!stat.isFile()) throw new Error("transcript_file must be a file.");
|
|
94
|
+
if (stat.size > MAX_TRANSCRIPT_FILE) throw new Error("The transcript file is over 5 MB.");
|
|
95
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
96
|
+
const text = ext === ".vtt" || ext === ".srt" ? parseSubtitles(raw) : raw.replace(/\r/g, "").trim();
|
|
97
|
+
if (!text) throw new Error("The transcript file has no text in it.");
|
|
98
|
+
return text;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------- ffmpeg
|
|
102
|
+
|
|
103
|
+
function run(exec, cmd, args, timeout) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
exec(cmd, args, { timeout, windowsHide: true, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
106
|
+
if (err) reject(Object.assign(err, { stderr }));
|
|
107
|
+
else resolve({ stdout, stderr });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function hasFfmpeg(exec) {
|
|
113
|
+
try {
|
|
114
|
+
await run(exec, "ffmpeg", ["-version"], 8000);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function compress(exec, input) {
|
|
122
|
+
const out = path.join(os.tmpdir(), `causly-${crypto.randomUUID()}.ogg`);
|
|
123
|
+
try {
|
|
124
|
+
await run(exec, "ffmpeg", ["-y", "-i", input, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "libopus", "-b:a", "24k", out], 15 * 60_000);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
fs.rmSync(out, { force: true });
|
|
127
|
+
throw new Error(`ffmpeg could not convert this file: ${String(err.stderr || err.message).split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300)}`);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------- Groq
|
|
133
|
+
|
|
134
|
+
function groqKey() {
|
|
135
|
+
const key = process.env.GROQ_API_KEY;
|
|
136
|
+
if (!key) throw new Error("Transcription is not set up. Missing: GROQ_API_KEY (free key at https://console.groq.com/keys, add it to .env).");
|
|
137
|
+
return key;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const mb = (bytes) => Math.round((bytes / 1024 / 1024) * 10) / 10;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Transcribe an audio or video file with Groq Whisper.
|
|
144
|
+
* Returns { transcript (timestamped lines), text, duration_seconds, language, model, file, recorded_at_ms, converted }.
|
|
145
|
+
*/
|
|
146
|
+
export async function transcribeAudio({ path: file, language, vocabulary } = {}, deps = {}) {
|
|
147
|
+
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
148
|
+
const exec = deps.exec ?? execFile;
|
|
149
|
+
if (!file || typeof file !== "string") throw new Error("path is required: the recording to transcribe.");
|
|
150
|
+
if (language !== undefined && !/^[a-z]{2,3}$/i.test(String(language))) throw new Error("language must be a 2-letter code like en or hi (leave it out to detect it).");
|
|
151
|
+
const key = groqKey();
|
|
152
|
+
|
|
153
|
+
let stat;
|
|
154
|
+
try {
|
|
155
|
+
stat = fs.statSync(file);
|
|
156
|
+
} catch {
|
|
157
|
+
throw new Error(`Cannot find the recording: ${path.basename(file)}.`);
|
|
158
|
+
}
|
|
159
|
+
if (!stat.isFile()) throw new Error("path must be a file.");
|
|
160
|
+
if (!stat.size) throw new Error("The recording is empty.");
|
|
161
|
+
|
|
162
|
+
const limit = (Number(process.env.GROQ_MAX_FILE_MB) > 0 ? Number(process.env.GROQ_MAX_FILE_MB) : 25) * 1024 * 1024;
|
|
163
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
164
|
+
let upload = file;
|
|
165
|
+
let converted = false;
|
|
166
|
+
let temp = null;
|
|
167
|
+
try {
|
|
168
|
+
if (!AUDIO_EXTS.includes(ext) || stat.size > limit) {
|
|
169
|
+
const why = !AUDIO_EXTS.includes(ext) ? `".${ext || "?"}" is not a format Groq accepts (${AUDIO_EXTS.join(", ")})` : `the file is ${mb(stat.size)} MB and the limit is ${mb(limit)} MB`;
|
|
170
|
+
if (!(await hasFfmpeg(exec))) {
|
|
171
|
+
throw new Error(`Cannot send this file: ${why}. Install ffmpeg (Windows: winget install ffmpeg) and the server will convert it automatically, or convert it yourself to mp3/m4a under ${mb(limit)} MB.`);
|
|
172
|
+
}
|
|
173
|
+
temp = await compress(exec, file);
|
|
174
|
+
upload = temp;
|
|
175
|
+
converted = true;
|
|
176
|
+
const size = fs.statSync(temp).size;
|
|
177
|
+
if (size > limit) throw new Error(`Even after compressing, the recording is ${mb(size)} MB (limit ${mb(limit)} MB). Split it into shorter parts, or set GROQ_MAX_FILE_MB=100 if your Groq account is on the dev tier.`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const form = new FormData();
|
|
181
|
+
form.append("file", new Blob([fs.readFileSync(upload)]), path.basename(upload));
|
|
182
|
+
const model = process.env.GROQ_WHISPER_MODEL || DEFAULT_MODEL;
|
|
183
|
+
form.append("model", model);
|
|
184
|
+
form.append("response_format", "verbose_json");
|
|
185
|
+
form.append("temperature", "0");
|
|
186
|
+
if (language) form.append("language", String(language).toLowerCase());
|
|
187
|
+
if (vocabulary && String(vocabulary).trim()) form.append("prompt", String(vocabulary).trim().slice(0, 800));
|
|
188
|
+
|
|
189
|
+
let res;
|
|
190
|
+
try {
|
|
191
|
+
res = await fetchFn(GROQ_URL, { method: "POST", headers: { authorization: `Bearer ${key}` }, body: form, signal: AbortSignal.timeout(10 * 60_000) });
|
|
192
|
+
} catch (err) {
|
|
193
|
+
throw new Error(`Could not reach Groq: ${String(err.message).split(key).join("[REDACTED]")}`);
|
|
194
|
+
}
|
|
195
|
+
const data = await res.json().catch(() => ({}));
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
const why = String(data?.error?.message || `HTTP ${res.status}`).split(key).join("[REDACTED]");
|
|
198
|
+
if (res.status === 401) throw new Error("Groq rejected the API key. Check GROQ_API_KEY.");
|
|
199
|
+
if (res.status === 413) throw new Error(`Groq says the file is too large (${why}).`);
|
|
200
|
+
if (res.status === 429) {
|
|
201
|
+
const wait = res.headers?.get?.("retry-after");
|
|
202
|
+
throw new Error(`Groq rate limit hit${wait ? `; retry in ${wait}s` : ""}. The free tier limits audio minutes per hour and per day.`);
|
|
203
|
+
}
|
|
204
|
+
throw new Error(`Groq error: ${why}`);
|
|
205
|
+
}
|
|
206
|
+
const text = String(data.text ?? "").trim();
|
|
207
|
+
if (!text) throw new Error("No speech was found in the recording.");
|
|
208
|
+
return {
|
|
209
|
+
transcript: formatSegments(data.segments, text),
|
|
210
|
+
text,
|
|
211
|
+
duration_seconds: Number(data.duration) || stat.size / 16_000,
|
|
212
|
+
language: data.language ?? null,
|
|
213
|
+
model,
|
|
214
|
+
file: path.basename(file),
|
|
215
|
+
recorded_at_ms: stat.mtimeMs,
|
|
216
|
+
converted,
|
|
217
|
+
};
|
|
218
|
+
} finally {
|
|
219
|
+
if (temp) fs.rmSync(temp, { force: true });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// "Is there a newer version?" Asked only by `status` and `doctor`, never while the server runs.
|
|
2
|
+
// One small request to the npm registry, remembered for a day. Turn it off with CAUSLY_UPDATE_CHECK=off.
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { causlyHome } from "./paths.js";
|
|
6
|
+
|
|
7
|
+
const num = (v) => String(v).replace(/^v/, "").split("-")[0].split(".").map((n) => Number(n) || 0);
|
|
8
|
+
|
|
9
|
+
/** > 0 when a is newer than b. */
|
|
10
|
+
export function compareVersions(a, b) {
|
|
11
|
+
const x = num(a);
|
|
12
|
+
const y = num(b);
|
|
13
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
14
|
+
const d = (x[i] || 0) - (y[i] || 0);
|
|
15
|
+
if (d) return d;
|
|
16
|
+
}
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function checkForUpdate({
|
|
21
|
+
current,
|
|
22
|
+
name = "causly-server",
|
|
23
|
+
fetch: fetchFn = globalThis.fetch,
|
|
24
|
+
env = process.env,
|
|
25
|
+
cacheFile = path.join(causlyHome(), ".update-check.json"),
|
|
26
|
+
now = Date.now,
|
|
27
|
+
maxAgeMs = 24 * 3_600_000,
|
|
28
|
+
} = {}) {
|
|
29
|
+
if (String(env.CAUSLY_UPDATE_CHECK || "").toLowerCase() === "off") return null;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
|
33
|
+
if (cached.latest && now() - cached.checked_at < maxAgeMs) {
|
|
34
|
+
return { current, latest: cached.latest, newer: compareVersions(cached.latest, current) > 0 };
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// no cache yet
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetchFn(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(3000) });
|
|
42
|
+
if (!res.ok) return null; // not published yet, or no network
|
|
43
|
+
const { version } = await res.json();
|
|
44
|
+
if (!version) return null;
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
47
|
+
fs.writeFileSync(cacheFile, JSON.stringify({ latest: version, checked_at: now() }));
|
|
48
|
+
} catch {
|
|
49
|
+
// the cache is optional
|
|
50
|
+
}
|
|
51
|
+
return { current, latest: version, newer: compareVersions(version, current) > 0 };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const updateMessage = (r) =>
|
|
58
|
+
r?.newer ? `A newer version is available: ${r.current} -> ${r.latest}. Update with: npm install -g causly-server@latest (then restart your editor).` : null;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const VERCEL_API = "https://api.vercel.com";
|
|
2
|
+
|
|
3
|
+
function getToken() {
|
|
4
|
+
const token = process.env.VERCEL_TOKEN;
|
|
5
|
+
if (!token) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
"VERCEL_TOKEN not set. Run `npm run setup` to configure it, or add it manually to your .env file: VERCEL_TOKEN=..."
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
return token;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function vercelFetch(path, options = {}) {
|
|
14
|
+
const token = getToken();
|
|
15
|
+
const res = await fetch(`${VERCEL_API}${path}`, {
|
|
16
|
+
...options,
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${token}`,
|
|
19
|
+
...(options.body ? { "Content-Type": "application/json" } : {}),
|
|
20
|
+
...options.headers,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
let data;
|
|
26
|
+
try {
|
|
27
|
+
data = text ? JSON.parse(text) : null;
|
|
28
|
+
} catch {
|
|
29
|
+
data = text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const message = data?.error?.message || res.statusText;
|
|
34
|
+
throw new Error(`Vercel API error (${res.status}): ${message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Get the authenticated Vercel user/team — connectivity/auth check. */
|
|
41
|
+
export async function vercelGetAuthenticatedUser() {
|
|
42
|
+
const data = await vercelFetch("/v2/user");
|
|
43
|
+
return { id: data.user.id, username: data.user.username, email: data.user.email };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** List projects for the authenticated user/team. */
|
|
47
|
+
export async function vercelListProjects({ limit = 20 }) {
|
|
48
|
+
const data = await vercelFetch(`/v9/projects?limit=${limit}`);
|
|
49
|
+
return {
|
|
50
|
+
projects: data.projects.map((p) => ({
|
|
51
|
+
id: p.id,
|
|
52
|
+
name: p.name,
|
|
53
|
+
framework: p.framework,
|
|
54
|
+
latest_url: p.targets?.production?.url || null,
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Get details of a single project by name or ID. */
|
|
60
|
+
export async function vercelGetProject({ project }) {
|
|
61
|
+
const data = await vercelFetch(`/v9/projects/${project}`);
|
|
62
|
+
return {
|
|
63
|
+
id: data.id,
|
|
64
|
+
name: data.name,
|
|
65
|
+
framework: data.framework,
|
|
66
|
+
link: data.link ? { type: data.link.type, repo: `${data.link.org}/${data.link.repo}` } : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** List recent deployments, optionally scoped to a project. */
|
|
71
|
+
export async function vercelListDeployments({ project, limit = 10 }) {
|
|
72
|
+
const query = project ? `?projectId=${project}&limit=${limit}` : `?limit=${limit}`;
|
|
73
|
+
const data = await vercelFetch(`/v6/deployments${query}`);
|
|
74
|
+
return {
|
|
75
|
+
deployments: data.deployments.map((d) => ({
|
|
76
|
+
uid: d.uid,
|
|
77
|
+
name: d.name,
|
|
78
|
+
url: d.url,
|
|
79
|
+
state: d.state,
|
|
80
|
+
created: new Date(d.created).toISOString(),
|
|
81
|
+
})),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Get the status/details of a specific deployment. */
|
|
86
|
+
export async function vercelGetDeployment({ deployment_id }) {
|
|
87
|
+
const data = await vercelFetch(`/v13/deployments/${deployment_id}`);
|
|
88
|
+
return {
|
|
89
|
+
uid: data.id,
|
|
90
|
+
name: data.name,
|
|
91
|
+
url: data.url,
|
|
92
|
+
state: data.readyState,
|
|
93
|
+
created: new Date(data.createdAt).toISOString(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve a GitHub "owner/repo" string to the numeric repo ID Vercel's
|
|
99
|
+
* gitSource requires. Uses GITHUB_TOKEN if set; falls back to an
|
|
100
|
+
* unauthenticated call for public repos if it isn't.
|
|
101
|
+
*/
|
|
102
|
+
async function resolveGithubRepoId(ownerSlashRepo) {
|
|
103
|
+
const headers = { Accept: "application/vnd.github+json", "User-Agent": "causly-server" };
|
|
104
|
+
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
105
|
+
|
|
106
|
+
const res = await fetch(`https://api.github.com/repos/${ownerSlashRepo}`, { headers });
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Could not resolve GitHub repo "${ownerSlashRepo}" to an ID (${res.status}). Check the owner/repo is correct and accessible.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
return data.id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Trigger a new deployment for a git-connected project by redeploying
|
|
118
|
+
* from a given git ref (branch/commit). Vercel's API needs the numeric
|
|
119
|
+
* GitHub repo ID (not just "owner/repo"), so this resolves it via the
|
|
120
|
+
* GitHub API automatically.
|
|
121
|
+
*/
|
|
122
|
+
export async function vercelCreateDeployment({ name, project, git_source_repo, git_source_ref = "main", git_source_type = "github" }) {
|
|
123
|
+
const repoId = await resolveGithubRepoId(git_source_repo);
|
|
124
|
+
|
|
125
|
+
const data = await vercelFetch("/v13/deployments", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
name,
|
|
129
|
+
project,
|
|
130
|
+
gitSource: {
|
|
131
|
+
type: git_source_type,
|
|
132
|
+
repoId,
|
|
133
|
+
ref: git_source_ref,
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
return { uid: data.id, url: data.url, state: data.readyState };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Delete a project. */
|
|
141
|
+
export async function vercelDeleteProject({ project }) {
|
|
142
|
+
await vercelFetch(`/v9/projects/${project}`, { method: "DELETE" });
|
|
143
|
+
return { project, deleted: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Get build/runtime logs for a deployment. */
|
|
147
|
+
export async function vercelGetDeploymentLogs({ deployment_id, limit = 200 }) {
|
|
148
|
+
const data = await vercelFetch(`/v3/deployments/${deployment_id}/events?limit=${limit}`);
|
|
149
|
+
const events = Array.isArray(data) ? data : data.events || [];
|
|
150
|
+
return {
|
|
151
|
+
deployment_id,
|
|
152
|
+
logs: events.map((e) => ({ type: e.type, text: e.text ?? e.payload?.text, created: e.created })),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Get deployment build/progress events (separate from raw logs — includes build step states). */
|
|
157
|
+
export async function vercelGetDeploymentEvents({ deployment_id }) {
|
|
158
|
+
const data = await vercelFetch(`/v13/deployments/${deployment_id}`);
|
|
159
|
+
return {
|
|
160
|
+
deployment_id,
|
|
161
|
+
state: data.readyState,
|
|
162
|
+
ready_substate: data.readySubstate,
|
|
163
|
+
checks_state: data.checksState,
|
|
164
|
+
build_errors: data.errorMessage ? { message: data.errorMessage, code: data.errorCode } : null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Cancel a currently building/queued deployment. */
|
|
169
|
+
export async function vercelCancelDeployment({ deployment_id }) {
|
|
170
|
+
const data = await vercelFetch(`/v12/deployments/${deployment_id}/cancel`, { method: "PATCH" });
|
|
171
|
+
return { deployment_id, state: data.readyState };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Hit a URL and report status/response time — used to verify a deployment is actually healthy after it goes live. */
|
|
175
|
+
export async function httpCheck({ url, timeout_ms = 10_000 }) {
|
|
176
|
+
const start = Date.now();
|
|
177
|
+
try {
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
const timer = setTimeout(() => controller.abort(), timeout_ms);
|
|
180
|
+
const res = await fetch(url, { method: "GET", redirect: "follow", signal: controller.signal });
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
const response_time_ms = Date.now() - start;
|
|
183
|
+
return {
|
|
184
|
+
url,
|
|
185
|
+
status: res.status,
|
|
186
|
+
healthy: res.status >= 200 && res.status < 400,
|
|
187
|
+
response_time_ms,
|
|
188
|
+
};
|
|
189
|
+
} catch (err) {
|
|
190
|
+
return {
|
|
191
|
+
url,
|
|
192
|
+
status: null,
|
|
193
|
+
healthy: false,
|
|
194
|
+
response_time_ms: Date.now() - start,
|
|
195
|
+
error: err.name === "AbortError" ? "timeout" : err.message,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import { verifyBoundaryAttestReceipt } from "./boundaryAttest.js";
|
|
5
|
+
|
|
6
|
+
export function verifyReceiptFile(receiptPath, expectedPublicKeyPath) {
|
|
7
|
+
return verifyBoundaryAttestReceipt(fs.readFileSync(receiptPath, "utf8"), fs.readFileSync(expectedPublicKeyPath, "utf8"));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
11
|
+
const [, , receiptPath, expectedPublicKeyPath] = process.argv;
|
|
12
|
+
if (!receiptPath || !expectedPublicKeyPath) {
|
|
13
|
+
console.error("Usage: node tools/verifyBoundaryAttest.js <receipt.json> <expected-ed25519-public-key.pem>");
|
|
14
|
+
process.exitCode = 2;
|
|
15
|
+
} else {
|
|
16
|
+
try {
|
|
17
|
+
const result = verifyReceiptFile(receiptPath, expectedPublicKeyPath);
|
|
18
|
+
console.log(JSON.stringify(result));
|
|
19
|
+
if (!result.ok) process.exitCode = 1;
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.log(JSON.stringify({ ok: false, reason: "read_error", detail: err.message }));
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|