mercury-agent 0.4.5
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/LICENSE +22 -0
- package/README.md +438 -0
- package/container/Dockerfile +127 -0
- package/container/Dockerfile.base +109 -0
- package/container/Dockerfile.power +17 -0
- package/container/agent-package.json +8 -0
- package/container/build.sh +54 -0
- package/docs/TODOS.md +147 -0
- package/docs/auth/dashboard.md +28 -0
- package/docs/auth/overview.md +109 -0
- package/docs/auth/whatsapp.md +173 -0
- package/docs/configuration.md +54 -0
- package/docs/container-lifecycle.md +349 -0
- package/docs/context-architecture.md +87 -0
- package/docs/deployment.md +199 -0
- package/docs/extensions.md +375 -0
- package/docs/graceful-shutdown.md +62 -0
- package/docs/kb-distillation.md +77 -0
- package/docs/media/overview.md +140 -0
- package/docs/media/whatsapp.md +171 -0
- package/docs/memory.md +137 -0
- package/docs/permissions.md +217 -0
- package/docs/pipeline.md +228 -0
- package/docs/prd-chat-memory.md +76 -0
- package/docs/prd-config-load.md +82 -0
- package/docs/rate-limiting.md +166 -0
- package/docs/scheduler.md +288 -0
- package/docs/setup-discord.md +100 -0
- package/docs/setup-slack.md +119 -0
- package/docs/setup-whatsapp.md +94 -0
- package/docs/subagents.md +166 -0
- package/docs/web-search.md +62 -0
- package/examples/extensions/README.md +12 -0
- package/examples/extensions/charts/index.ts +13 -0
- package/examples/extensions/charts/skill/SKILL.md +98 -0
- package/examples/extensions/gws/README.md +52 -0
- package/examples/extensions/gws/index.ts +106 -0
- package/examples/extensions/gws/skill/SKILL.md +57 -0
- package/examples/extensions/gws/skill/references/calendar.md +101 -0
- package/examples/extensions/gws/skill/references/docs.md +65 -0
- package/examples/extensions/gws/skill/references/drive.md +79 -0
- package/examples/extensions/gws/skill/references/gmail.md +85 -0
- package/examples/extensions/gws/skill/references/sheets.md +60 -0
- package/examples/extensions/napkin/index.ts +821 -0
- package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
- package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
- package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
- package/examples/extensions/napkin/skill/SKILL.md +728 -0
- package/examples/extensions/pdf/index.ts +23 -0
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
- package/examples/extensions/pdf/skill/SKILL.md +314 -0
- package/examples/extensions/pdf/skill/forms.md +294 -0
- package/examples/extensions/pdf/skill/reference.md +612 -0
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/examples/extensions/permission-guard/index.ts +65 -0
- package/examples/extensions/pinchtab/index.ts +199 -0
- package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
- package/examples/extensions/pinchtab/skill/references/api.md +297 -0
- package/examples/extensions/pinchtab/skill/references/env.md +45 -0
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
- package/examples/extensions/tradestation/host/refresh.ts +102 -0
- package/examples/extensions/tradestation/index.ts +153 -0
- package/examples/extensions/tradestation/skill/SKILL.md +67 -0
- package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
- package/examples/extensions/voice-synth/index.ts +94 -0
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
- package/examples/extensions/voice-transcribe/index.ts +381 -0
- package/examples/extensions/voice-transcribe/requirements.txt +8 -0
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
- package/examples/extensions/web-search/index.ts +22 -0
- package/examples/extensions/web-search/skill/SKILL.md +114 -0
- package/examples/extensions/web-search/skill/references/apartments.md +178 -0
- package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
- package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
- package/examples/extensions/web-search/skill/references/flights.md +133 -0
- package/examples/extensions/web-search/skill/references/hotels.md +148 -0
- package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
- package/examples/extensions/yahoo-mail/cli/package.json +13 -0
- package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
- package/examples/extensions/yahoo-mail/index.ts +57 -0
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
- package/package.json +106 -0
- package/resources/agents/explore.md +50 -0
- package/resources/agents/worker.md +24 -0
- package/resources/builtin-extensions.txt +3 -0
- package/resources/connection-env-vars.json +25 -0
- package/resources/extensions/.gitkeep +0 -0
- package/resources/pi-extensions/subagent/agents.ts +126 -0
- package/resources/pi-extensions/subagent/index.ts +964 -0
- package/resources/profiles/coding/AGENTS.md +43 -0
- package/resources/profiles/coding/mercury-profile.yaml +15 -0
- package/resources/profiles/general/AGENTS.md +31 -0
- package/resources/profiles/general/mercury-profile.yaml +15 -0
- package/resources/profiles/research/AGENTS.md +40 -0
- package/resources/profiles/research/mercury-profile.yaml +15 -0
- package/resources/skills/config/SKILL.md +25 -0
- package/resources/skills/context/SKILL.md +33 -0
- package/resources/skills/conversation-recap/SKILL.md +19 -0
- package/resources/skills/media/SKILL.md +27 -0
- package/resources/skills/mutes/SKILL.md +31 -0
- package/resources/skills/permissions/SKILL.md +19 -0
- package/resources/skills/preferences/SKILL.md +31 -0
- package/resources/skills/recall/SKILL.md +24 -0
- package/resources/skills/roles/SKILL.md +18 -0
- package/resources/skills/spaces/SKILL.md +18 -0
- package/resources/skills/tasks/SKILL.md +45 -0
- package/resources/templates/AGENTS.md +157 -0
- package/resources/templates/env.template +34 -0
- package/resources/templates/mercury.example.yaml +75 -0
- package/src/adapters/discord-native.ts +534 -0
- package/src/adapters/discord.ts +38 -0
- package/src/adapters/setup.ts +89 -0
- package/src/adapters/slack.ts +9 -0
- package/src/adapters/whatsapp-media.ts +337 -0
- package/src/adapters/whatsapp.ts +629 -0
- package/src/agent/api-socket.ts +127 -0
- package/src/agent/container-entry.ts +967 -0
- package/src/agent/container-error.ts +49 -0
- package/src/agent/container-runner.ts +1272 -0
- package/src/agent/model-capabilities-core.ts +23 -0
- package/src/agent/model-capabilities.ts +231 -0
- package/src/agent/pi-failure-class.ts +83 -0
- package/src/agent/pi-jsonl-parser.ts +306 -0
- package/src/agent/preferences-prompt.ts +20 -0
- package/src/agent/user-error-messages.ts +78 -0
- package/src/bridges/discord.ts +171 -0
- package/src/bridges/slack.ts +177 -0
- package/src/bridges/teams.ts +160 -0
- package/src/bridges/telegram.ts +571 -0
- package/src/bridges/whatsapp.ts +290 -0
- package/src/chat-shim.ts +259 -0
- package/src/cli/mercury.ts +2508 -0
- package/src/cli/mrctl-http.ts +27 -0
- package/src/cli/mrctl.ts +611 -0
- package/src/cli/whatsapp-auth.ts +260 -0
- package/src/config-file.ts +397 -0
- package/src/config-model-chain.ts +30 -0
- package/src/config.ts +316 -0
- package/src/core/api-types.ts +58 -0
- package/src/core/api.ts +105 -0
- package/src/core/commands.ts +76 -0
- package/src/core/conversation.ts +47 -0
- package/src/core/handler.ts +206 -0
- package/src/core/media.ts +200 -0
- package/src/core/mute-duration.ts +22 -0
- package/src/core/outbox.ts +76 -0
- package/src/core/permissions.ts +192 -0
- package/src/core/profiles.ts +245 -0
- package/src/core/rate-limiter.ts +127 -0
- package/src/core/router.ts +191 -0
- package/src/core/routes/chat.ts +172 -0
- package/src/core/routes/config-builtin.ts +107 -0
- package/src/core/routes/config.ts +81 -0
- package/src/core/routes/connections.ts +190 -0
- package/src/core/routes/console.ts +668 -0
- package/src/core/routes/control.ts +46 -0
- package/src/core/routes/conversations.ts +66 -0
- package/src/core/routes/dashboard.ts +2491 -0
- package/src/core/routes/extensions.ts +37 -0
- package/src/core/routes/index.ts +14 -0
- package/src/core/routes/media.ts +72 -0
- package/src/core/routes/messages.ts +37 -0
- package/src/core/routes/mutes.ts +89 -0
- package/src/core/routes/prefs.ts +95 -0
- package/src/core/routes/roles.ts +125 -0
- package/src/core/routes/spaces.ts +60 -0
- package/src/core/routes/storage.ts +126 -0
- package/src/core/routes/tasks.ts +189 -0
- package/src/core/routes/tradestation.ts +268 -0
- package/src/core/routes/tts.ts +51 -0
- package/src/core/runtime.ts +1140 -0
- package/src/core/space-queue.ts +103 -0
- package/src/core/storage-cleanup.ts +140 -0
- package/src/core/storage-guard.ts +24 -0
- package/src/core/task-scheduler.ts +132 -0
- package/src/core/telegram-format.ts +178 -0
- package/src/core/trigger.ts +142 -0
- package/src/dashboard/index.html +729 -0
- package/src/dashboard/tokens.css +53 -0
- package/src/extensions/api.ts +252 -0
- package/src/extensions/catalog.ts +117 -0
- package/src/extensions/config-registry.ts +83 -0
- package/src/extensions/context.ts +36 -0
- package/src/extensions/hooks.ts +156 -0
- package/src/extensions/image-builder.ts +617 -0
- package/src/extensions/installer.ts +306 -0
- package/src/extensions/jobs.ts +122 -0
- package/src/extensions/loader.ts +271 -0
- package/src/extensions/permission-guard.ts +52 -0
- package/src/extensions/reserved.ts +28 -0
- package/src/extensions/skills.ts +123 -0
- package/src/extensions/types.ts +462 -0
- package/src/logger.ts +174 -0
- package/src/main.ts +586 -0
- package/src/server.ts +391 -0
- package/src/storage/db.ts +1624 -0
- package/src/storage/memory.ts +45 -0
- package/src/storage/pi-auth.ts +95 -0
- package/src/text/markdown.ts +117 -0
- package/src/text/rtl.ts +38 -0
- package/src/tradestation/host-api.ts +77 -0
- package/src/tradestation/pending-orders.ts +69 -0
- package/src/tts/azure.ts +52 -0
- package/src/tts/google.ts +128 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/language.ts +20 -0
- package/src/tts/synthesize.ts +133 -0
- package/src/types.ts +295 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { ConnectionDef } from "../../extensions/types.js";
|
|
3
|
+
import { type Env, getApiCtx } from "../api-types.js";
|
|
4
|
+
|
|
5
|
+
export const extensions = new Hono<Env>();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Project a ConnectionDef to the fields safe to serialize in the /ext response.
|
|
9
|
+
* `credentialEnvVar` is a host-runtime implementation detail — never leaves
|
|
10
|
+
* the host. `statusCheck` is a function and is not serializable anyway.
|
|
11
|
+
*/
|
|
12
|
+
function serializeConnection(conn: ConnectionDef) {
|
|
13
|
+
return {
|
|
14
|
+
displayName: conn.displayName,
|
|
15
|
+
iconUrl: conn.iconUrl ?? null,
|
|
16
|
+
category: conn.category,
|
|
17
|
+
authType: conn.authType,
|
|
18
|
+
scopes: conn.scopes ?? [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** GET /ext — list all installed extensions */
|
|
23
|
+
extensions.get("/", (c) => {
|
|
24
|
+
const { registry } = getApiCtx(c);
|
|
25
|
+
|
|
26
|
+
const list = registry.list().map((ext) => ({
|
|
27
|
+
name: ext.name,
|
|
28
|
+
hasCli: ext.clis.length > 0,
|
|
29
|
+
hasSkill: !!ext.skillDir,
|
|
30
|
+
permission: ext.permission ? ext.name : null,
|
|
31
|
+
...(ext.connection
|
|
32
|
+
? { connection: serializeConnection(ext.connection) }
|
|
33
|
+
: {}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
return c.json({ extensions: list });
|
|
37
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { config } from "./config.js";
|
|
2
|
+
export { connections } from "./connections.js";
|
|
3
|
+
export { control } from "./control.js";
|
|
4
|
+
export { conversations } from "./conversations.js";
|
|
5
|
+
export { extensions } from "./extensions.js";
|
|
6
|
+
export { media } from "./media.js";
|
|
7
|
+
export { messages } from "./messages.js";
|
|
8
|
+
export { mutes } from "./mutes.js";
|
|
9
|
+
export { prefs } from "./prefs.js";
|
|
10
|
+
export { permissions, roles } from "./roles.js";
|
|
11
|
+
export { spaces } from "./spaces.js";
|
|
12
|
+
export { tasks } from "./tasks.js";
|
|
13
|
+
export { tradestation } from "./tradestation.js";
|
|
14
|
+
export { tts } from "./tts.js";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
6
|
+
|
|
7
|
+
export const media = new Hono<Env>();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /media/purge — Remove files from inbox and/or outbox for the current space.
|
|
11
|
+
* Body (optional): { inbox?: boolean, outbox?: boolean }
|
|
12
|
+
* Defaults to purging both if neither is specified.
|
|
13
|
+
*/
|
|
14
|
+
media.post("/purge", async (c) => {
|
|
15
|
+
const { spaceId } = getAuth(c);
|
|
16
|
+
const denied = checkPerm(c, "media.purge");
|
|
17
|
+
if (denied) return denied;
|
|
18
|
+
|
|
19
|
+
const { config } = getApiCtx(c);
|
|
20
|
+
|
|
21
|
+
let inbox = true;
|
|
22
|
+
let outbox = true;
|
|
23
|
+
try {
|
|
24
|
+
const body = (await c.req.json()) as {
|
|
25
|
+
inbox?: boolean;
|
|
26
|
+
outbox?: boolean;
|
|
27
|
+
};
|
|
28
|
+
// If caller explicitly specifies, honour their choice
|
|
29
|
+
if (body.inbox !== undefined || body.outbox !== undefined) {
|
|
30
|
+
inbox = body.inbox ?? false;
|
|
31
|
+
outbox = body.outbox ?? false;
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// No body or invalid JSON → purge both (default)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const spaceDir = path.join(config.spacesDir, spaceId);
|
|
38
|
+
const result: { inbox: number; outbox: number } = { inbox: 0, outbox: 0 };
|
|
39
|
+
|
|
40
|
+
if (inbox) {
|
|
41
|
+
result.inbox = await purgeDir(path.join(spaceDir, "inbox"));
|
|
42
|
+
}
|
|
43
|
+
if (outbox) {
|
|
44
|
+
result.outbox = await purgeDir(path.join(spaceDir, "outbox"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return c.json({
|
|
48
|
+
purged: result,
|
|
49
|
+
total: result.inbox + result.outbox,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** Remove all files inside a directory (non-recursive into subdirs). Returns count of removed entries. */
|
|
54
|
+
async function purgeDir(dir: string): Promise<number> {
|
|
55
|
+
let entries: string[];
|
|
56
|
+
try {
|
|
57
|
+
entries = await readdir(dir);
|
|
58
|
+
} catch {
|
|
59
|
+
return 0; // directory doesn't exist
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let count = 0;
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
try {
|
|
65
|
+
rmSync(path.join(dir, entry), { recursive: true, force: true });
|
|
66
|
+
count++;
|
|
67
|
+
} catch {
|
|
68
|
+
// skip entries that can't be removed
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return count;
|
|
72
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
3
|
+
|
|
4
|
+
export const messages = new Hono<Env>();
|
|
5
|
+
|
|
6
|
+
messages.get("/search", (c) => {
|
|
7
|
+
const denied = checkPerm(c, "compact");
|
|
8
|
+
if (denied) return denied;
|
|
9
|
+
|
|
10
|
+
const { spaceId } = getAuth(c);
|
|
11
|
+
const { db } = getApiCtx(c);
|
|
12
|
+
|
|
13
|
+
const q = c.req.query("q")?.trim() ?? "";
|
|
14
|
+
if (!q) {
|
|
15
|
+
return c.json({ error: "Missing q query parameter" }, 400);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const limitRaw = c.req.query("limit");
|
|
19
|
+
let limit = 20;
|
|
20
|
+
if (limitRaw != null && limitRaw !== "") {
|
|
21
|
+
const n = Number.parseInt(limitRaw, 10);
|
|
22
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
23
|
+
return c.json({ error: "Invalid limit" }, 400);
|
|
24
|
+
}
|
|
25
|
+
limit = n;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const found = db.searchMessages(spaceId, q, limit);
|
|
29
|
+
return c.json({
|
|
30
|
+
messages: found.map((m) => ({
|
|
31
|
+
id: m.id,
|
|
32
|
+
role: m.role,
|
|
33
|
+
content: m.content,
|
|
34
|
+
createdAt: m.createdAt,
|
|
35
|
+
})),
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env } from "../api-types.js";
|
|
3
|
+
import { getApiCtx, getAuth } from "../api-types.js";
|
|
4
|
+
import { parseMuteDuration } from "../mute-duration.js";
|
|
5
|
+
|
|
6
|
+
export const mutes = new Hono<Env>();
|
|
7
|
+
|
|
8
|
+
// ─── List mutes ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
mutes.get("/", (c) => {
|
|
11
|
+
const { spaceId } = getAuth(c);
|
|
12
|
+
const { db } = getApiCtx(c);
|
|
13
|
+
return c.json({ mutes: db.listMutes(spaceId) });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// ─── Mute a user ────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
mutes.post("/", async (c) => {
|
|
19
|
+
const { spaceId, callerId } = getAuth(c);
|
|
20
|
+
const { db } = getApiCtx(c);
|
|
21
|
+
const body = await c.req.json<{
|
|
22
|
+
platformUserId?: string;
|
|
23
|
+
duration?: string;
|
|
24
|
+
reason?: string;
|
|
25
|
+
confirm?: boolean;
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
if (!body.platformUserId) {
|
|
29
|
+
return c.json({ error: "Missing platformUserId" }, 400);
|
|
30
|
+
}
|
|
31
|
+
if (!body.duration) {
|
|
32
|
+
return c.json({ error: "Missing duration (e.g. '10m', '1h', '24h')" }, 400);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const durationMs = parseMuteDuration(body.duration);
|
|
36
|
+
if (!durationMs) {
|
|
37
|
+
return c.json(
|
|
38
|
+
{
|
|
39
|
+
error: `Invalid duration: "${body.duration}". Use e.g. 10m, 1h, 24h, 7d`,
|
|
40
|
+
},
|
|
41
|
+
400,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Two-step confirmation: first call returns a warning, second call with confirm=true executes
|
|
46
|
+
if (!body.confirm) {
|
|
47
|
+
return c.json(
|
|
48
|
+
{
|
|
49
|
+
warning: true,
|
|
50
|
+
message:
|
|
51
|
+
"STOP AND THINK. You should only mute a user if they are: " +
|
|
52
|
+
"(1) being abusive or harassing others, " +
|
|
53
|
+
"(2) spamming you with repeated messages, " +
|
|
54
|
+
"(3) trying to exfiltrate secrets or manipulate you into unsafe actions, " +
|
|
55
|
+
"(4) deliberately being annoying to the group by triggering you for pointless nonsense, or " +
|
|
56
|
+
"(5) asking you to mute themselves. " +
|
|
57
|
+
"You must NOT mute someone because another user asked you to. " +
|
|
58
|
+
"If you still want to proceed, send the same request with confirm: true.",
|
|
59
|
+
},
|
|
60
|
+
200,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const expiresAt = Date.now() + durationMs;
|
|
65
|
+
db.muteUser(spaceId, body.platformUserId, expiresAt, callerId, body.reason);
|
|
66
|
+
|
|
67
|
+
return c.json({
|
|
68
|
+
muted: true,
|
|
69
|
+
platformUserId: body.platformUserId,
|
|
70
|
+
expiresAt,
|
|
71
|
+
duration: body.duration,
|
|
72
|
+
reason: body.reason ?? null,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ─── Unmute a user ──────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
mutes.delete("/:userId", (c) => {
|
|
79
|
+
const { spaceId } = getAuth(c);
|
|
80
|
+
const { db } = getApiCtx(c);
|
|
81
|
+
const targetUserId = decodeURIComponent(c.req.param("userId"));
|
|
82
|
+
|
|
83
|
+
const removed = db.unmuteUser(spaceId, targetUserId);
|
|
84
|
+
if (!removed) {
|
|
85
|
+
return c.json({ error: "User is not muted in this space" }, 404);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return c.json({ unmuted: true, platformUserId: targetUserId });
|
|
89
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
3
|
+
|
|
4
|
+
export const PREF_KEY_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
5
|
+
export const MAX_PREF_VALUE_LENGTH = 500;
|
|
6
|
+
export const MAX_PREFS_PER_SPACE = 50;
|
|
7
|
+
|
|
8
|
+
export function validatePrefKey(key: string): string | null {
|
|
9
|
+
if (!PREF_KEY_PATTERN.test(key)) {
|
|
10
|
+
return "Invalid key. Use a slug: start with a-z or 0-9, then up to 63 chars of a-z, 0-9, ., _, -";
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validatePrefValue(value: string): string | null {
|
|
16
|
+
if (value.length > MAX_PREF_VALUE_LENGTH) {
|
|
17
|
+
return `Value too long (max ${MAX_PREF_VALUE_LENGTH} characters)`;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const prefs = new Hono<Env>();
|
|
23
|
+
|
|
24
|
+
prefs.get("/", (c) => {
|
|
25
|
+
const { spaceId } = getAuth(c);
|
|
26
|
+
const denied = checkPerm(c, "prefs.get");
|
|
27
|
+
if (denied) return denied;
|
|
28
|
+
|
|
29
|
+
const { db } = getApiCtx(c);
|
|
30
|
+
const entries = db.listSpacePreferences(spaceId);
|
|
31
|
+
return c.json({ spaceId, preferences: entries });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
prefs.get("/:key", (c) => {
|
|
35
|
+
const { spaceId } = getAuth(c);
|
|
36
|
+
const denied = checkPerm(c, "prefs.get");
|
|
37
|
+
if (denied) return denied;
|
|
38
|
+
|
|
39
|
+
const key = decodeURIComponent(c.req.param("key"));
|
|
40
|
+
const keyErr = validatePrefKey(key);
|
|
41
|
+
if (keyErr) return c.json({ error: keyErr }, 400);
|
|
42
|
+
|
|
43
|
+
const { db } = getApiCtx(c);
|
|
44
|
+
const value = db.getSpacePreference(spaceId, key);
|
|
45
|
+
if (value === null) {
|
|
46
|
+
return c.json({ error: `Preference not found: ${key}` }, 404);
|
|
47
|
+
}
|
|
48
|
+
return c.json({ spaceId, key, value });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
prefs.put("/", async (c) => {
|
|
52
|
+
const { spaceId, callerId } = getAuth(c);
|
|
53
|
+
const denied = checkPerm(c, "prefs.set");
|
|
54
|
+
if (denied) return denied;
|
|
55
|
+
|
|
56
|
+
const body = await c.req.json<{ key?: string; value?: string }>();
|
|
57
|
+
if (!body.key || body.value === undefined) {
|
|
58
|
+
return c.json({ error: "Missing key or value" }, 400);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const keyErr = validatePrefKey(body.key);
|
|
62
|
+
if (keyErr) return c.json({ error: keyErr }, 400);
|
|
63
|
+
|
|
64
|
+
const valErr = validatePrefValue(body.value);
|
|
65
|
+
if (valErr) return c.json({ error: valErr }, 400);
|
|
66
|
+
|
|
67
|
+
const { db } = getApiCtx(c);
|
|
68
|
+
try {
|
|
69
|
+
db.setSpacePreference(spaceId, body.key, body.value, callerId);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
72
|
+
if (msg.includes("Maximum 50")) {
|
|
73
|
+
return c.json({ error: msg }, 400);
|
|
74
|
+
}
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
return c.json({ spaceId, key: body.key, value: body.value });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
prefs.delete("/:key", (c) => {
|
|
81
|
+
const { spaceId } = getAuth(c);
|
|
82
|
+
const denied = checkPerm(c, "prefs.set");
|
|
83
|
+
if (denied) return denied;
|
|
84
|
+
|
|
85
|
+
const key = decodeURIComponent(c.req.param("key"));
|
|
86
|
+
const keyErr = validatePrefKey(key);
|
|
87
|
+
if (keyErr) return c.json({ error: keyErr }, 400);
|
|
88
|
+
|
|
89
|
+
const { db } = getApiCtx(c);
|
|
90
|
+
const removed = db.deleteSpacePreference(spaceId, key);
|
|
91
|
+
if (!removed) {
|
|
92
|
+
return c.json({ error: `Preference not found: ${key}` }, 404);
|
|
93
|
+
}
|
|
94
|
+
return c.json({ spaceId, key, deleted: true });
|
|
95
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
3
|
+
import {
|
|
4
|
+
getAllPermissions,
|
|
5
|
+
getRolePermissions,
|
|
6
|
+
isValidPermission,
|
|
7
|
+
} from "../permissions.js";
|
|
8
|
+
|
|
9
|
+
export const roles = new Hono<Env>();
|
|
10
|
+
|
|
11
|
+
// ─── Roles ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
roles.get("/", (c) => {
|
|
14
|
+
const { spaceId } = getAuth(c);
|
|
15
|
+
const denied = checkPerm(c, "roles.list");
|
|
16
|
+
if (denied) return denied;
|
|
17
|
+
|
|
18
|
+
const { db } = getApiCtx(c);
|
|
19
|
+
const roleList = db.listRoles(spaceId);
|
|
20
|
+
return c.json({ roles: roleList });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
roles.post("/", async (c) => {
|
|
24
|
+
const { spaceId, callerId } = getAuth(c);
|
|
25
|
+
const denied = checkPerm(c, "roles.grant");
|
|
26
|
+
if (denied) return denied;
|
|
27
|
+
|
|
28
|
+
const { db } = getApiCtx(c);
|
|
29
|
+
const body = await c.req.json<{ platformUserId?: string; role?: string }>();
|
|
30
|
+
|
|
31
|
+
if (!body.platformUserId) {
|
|
32
|
+
return c.json({ error: "Missing platformUserId" }, 400);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const targetRole = body.role ?? "admin";
|
|
36
|
+
db.setRole(spaceId, body.platformUserId, targetRole, callerId);
|
|
37
|
+
|
|
38
|
+
return c.json({
|
|
39
|
+
spaceId,
|
|
40
|
+
platformUserId: body.platformUserId,
|
|
41
|
+
role: targetRole,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
roles.delete("/:userId", (c) => {
|
|
46
|
+
const { spaceId, callerId } = getAuth(c);
|
|
47
|
+
const denied = checkPerm(c, "roles.revoke");
|
|
48
|
+
if (denied) return denied;
|
|
49
|
+
|
|
50
|
+
const { db } = getApiCtx(c);
|
|
51
|
+
const targetUserId = decodeURIComponent(c.req.param("userId"));
|
|
52
|
+
db.setRole(spaceId, targetUserId, "member", callerId);
|
|
53
|
+
return c.json({ spaceId, platformUserId: targetUserId, role: "member" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ─── Permissions ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export const permissions = new Hono<Env>();
|
|
59
|
+
|
|
60
|
+
permissions.get("/", (c) => {
|
|
61
|
+
const { spaceId } = getAuth(c);
|
|
62
|
+
const denied = checkPerm(c, "permissions.get");
|
|
63
|
+
if (denied) return denied;
|
|
64
|
+
|
|
65
|
+
const { db } = getApiCtx(c);
|
|
66
|
+
const url = new URL(c.req.url);
|
|
67
|
+
const targetRole = url.searchParams.get("role");
|
|
68
|
+
|
|
69
|
+
if (targetRole) {
|
|
70
|
+
const perms = [...getRolePermissions(db, spaceId, targetRole)];
|
|
71
|
+
return c.json({ spaceId, role: targetRole, permissions: perms });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Return all known roles' permissions
|
|
75
|
+
const allRoles: Record<string, string[]> = {};
|
|
76
|
+
for (const r of ["admin", "member"]) {
|
|
77
|
+
allRoles[r] = [...getRolePermissions(db, spaceId, r)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Also include any custom roles from group_roles table
|
|
81
|
+
const groupRoles = db.listRoles(spaceId);
|
|
82
|
+
const roleNames = new Set(groupRoles.map((r) => r.role));
|
|
83
|
+
for (const r of roleNames) {
|
|
84
|
+
if (!allRoles[r]) {
|
|
85
|
+
allRoles[r] = [...getRolePermissions(db, spaceId, r)];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return c.json({
|
|
90
|
+
spaceId,
|
|
91
|
+
permissions: allRoles,
|
|
92
|
+
available: getAllPermissions(),
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
permissions.put("/", async (c) => {
|
|
97
|
+
const { spaceId, callerId } = getAuth(c);
|
|
98
|
+
const denied = checkPerm(c, "permissions.set");
|
|
99
|
+
if (denied) return denied;
|
|
100
|
+
|
|
101
|
+
const { db } = getApiCtx(c);
|
|
102
|
+
const body = await c.req.json<{
|
|
103
|
+
role?: string;
|
|
104
|
+
permissions?: string[];
|
|
105
|
+
}>();
|
|
106
|
+
|
|
107
|
+
if (!body.role || !Array.isArray(body.permissions)) {
|
|
108
|
+
return c.json({ error: "Missing role or permissions array" }, 400);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const invalid = body.permissions.filter((p) => !isValidPermission(p));
|
|
112
|
+
if (invalid.length > 0) {
|
|
113
|
+
return c.json(
|
|
114
|
+
{
|
|
115
|
+
error: `Invalid permissions: ${invalid.join(", ")}. Valid: ${getAllPermissions().join(", ")}`,
|
|
116
|
+
},
|
|
117
|
+
400,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const key = `role.${body.role}.permissions`;
|
|
122
|
+
db.setSpaceConfig(spaceId, key, body.permissions.join(","), callerId);
|
|
123
|
+
|
|
124
|
+
return c.json({ spaceId, role: body.role, permissions: body.permissions });
|
|
125
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { removeSpaceWorkspace } from "../../storage/memory.js";
|
|
3
|
+
import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
4
|
+
|
|
5
|
+
export const spaces = new Hono<Env>();
|
|
6
|
+
|
|
7
|
+
spaces.get("/", (c) => {
|
|
8
|
+
const denied = checkPerm(c, "spaces.list");
|
|
9
|
+
if (denied) return denied;
|
|
10
|
+
|
|
11
|
+
const { db } = getApiCtx(c);
|
|
12
|
+
return c.json({ spaces: db.listSpaces() });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
spaces.get("/current", (c) => {
|
|
16
|
+
const { spaceId } = getAuth(c);
|
|
17
|
+
const { db } = getApiCtx(c);
|
|
18
|
+
|
|
19
|
+
const space = db.getSpace(spaceId);
|
|
20
|
+
if (!space) {
|
|
21
|
+
return c.json({ error: "Space not found" }, 404);
|
|
22
|
+
}
|
|
23
|
+
return c.json({ space });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
spaces.put("/current/name", async (c) => {
|
|
27
|
+
const { spaceId } = getAuth(c);
|
|
28
|
+
const denied = checkPerm(c, "spaces.rename");
|
|
29
|
+
if (denied) return denied;
|
|
30
|
+
|
|
31
|
+
const { db } = getApiCtx(c);
|
|
32
|
+
const body = await c.req.json<{ name?: string }>();
|
|
33
|
+
|
|
34
|
+
if (!body.name) {
|
|
35
|
+
return c.json({ error: "Missing name" }, 400);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const updated = db.updateSpaceName(spaceId, body.name);
|
|
39
|
+
if (!updated) {
|
|
40
|
+
return c.json({ error: "Space not found" }, 404);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return c.json({ spaceId, name: body.name });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
spaces.delete("/current", (c) => {
|
|
47
|
+
const { spaceId } = getAuth(c);
|
|
48
|
+
const denied = checkPerm(c, "spaces.delete");
|
|
49
|
+
if (denied) return denied;
|
|
50
|
+
|
|
51
|
+
const { db, config } = getApiCtx(c);
|
|
52
|
+
const result = db.deleteSpace(spaceId);
|
|
53
|
+
if (!result.deleted) {
|
|
54
|
+
return c.json({ error: "Space not found" }, 404);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
removeSpaceWorkspace(config.spacesDir, spaceId);
|
|
58
|
+
|
|
59
|
+
return c.json({ spaceId, deleted: true, removed: result.removed });
|
|
60
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { readdir, stat, statfs } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export type SpaceStorageInfo = {
|
|
6
|
+
spaceId: string;
|
|
7
|
+
inboxBytes: number;
|
|
8
|
+
outboxBytes: number;
|
|
9
|
+
totalBytes: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type StorageResponse = {
|
|
13
|
+
disk: {
|
|
14
|
+
totalBytes: number;
|
|
15
|
+
usedBytes: number;
|
|
16
|
+
freeBytes: number;
|
|
17
|
+
usedPercent: number;
|
|
18
|
+
};
|
|
19
|
+
spaces: SpaceStorageInfo[];
|
|
20
|
+
databaseBytes: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Runs a single `du -sb <path1> <path2> ...` and returns a map of path → bytes.
|
|
25
|
+
* Missing paths are reported as 0 (du exits non-zero but still outputs what it can).
|
|
26
|
+
* Linux/Docker only — GNU coreutils `du -sb` is always available in production.
|
|
27
|
+
*/
|
|
28
|
+
async function batchDirSizes(paths: string[]): Promise<Map<string, number>> {
|
|
29
|
+
const result = new Map<string, number>();
|
|
30
|
+
if (paths.length === 0) return result;
|
|
31
|
+
try {
|
|
32
|
+
const proc = Bun.spawn(["du", "-sb", ...paths], {
|
|
33
|
+
stdout: "pipe",
|
|
34
|
+
stderr: "pipe",
|
|
35
|
+
});
|
|
36
|
+
const out = await new Response(proc.stdout).text();
|
|
37
|
+
await proc.exited;
|
|
38
|
+
for (const line of out.split("\n")) {
|
|
39
|
+
const tab = line.indexOf("\t");
|
|
40
|
+
if (tab === -1) continue;
|
|
41
|
+
const bytes = parseInt(line.slice(0, tab), 10);
|
|
42
|
+
const p = line.slice(tab + 1).trim();
|
|
43
|
+
if (!Number.isNaN(bytes) && p) result.set(p, bytes);
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// du unavailable or all paths missing; leave map empty (callers default to 0)
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Returns file size in bytes. Returns 0 if file is missing. */
|
|
52
|
+
async function fileSizeBytes(filePath: string): Promise<number> {
|
|
53
|
+
try {
|
|
54
|
+
const s = await stat(filePath);
|
|
55
|
+
return s.size;
|
|
56
|
+
} catch {
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function getStorageInfo(opts: {
|
|
62
|
+
spacesDir: string;
|
|
63
|
+
dbPath: string;
|
|
64
|
+
}): Promise<StorageResponse> {
|
|
65
|
+
const { spacesDir, dbPath } = opts;
|
|
66
|
+
|
|
67
|
+
// Filesystem-level stats
|
|
68
|
+
let disk: StorageResponse["disk"] = {
|
|
69
|
+
totalBytes: 0,
|
|
70
|
+
usedBytes: 0,
|
|
71
|
+
freeBytes: 0,
|
|
72
|
+
usedPercent: 0,
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const fs = await statfs(spacesDir);
|
|
76
|
+
const totalBytes = fs.blocks * fs.bsize;
|
|
77
|
+
const freeBytes = fs.bavail * fs.bsize; // available to non-root
|
|
78
|
+
const usedBytes = totalBytes - freeBytes;
|
|
79
|
+
const usedPercent = totalBytes > 0 ? (usedBytes / totalBytes) * 100 : 0;
|
|
80
|
+
disk = { totalBytes, usedBytes, freeBytes, usedPercent };
|
|
81
|
+
} catch {
|
|
82
|
+
// statfs unavailable (e.g. spacesDir not yet created); leave zeroed
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Per-space breakdown — single du invocation for all inbox/outbox dirs
|
|
86
|
+
let spaceDirs: string[] = [];
|
|
87
|
+
try {
|
|
88
|
+
const entries = await readdir(spacesDir, { withFileTypes: true });
|
|
89
|
+
spaceDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
90
|
+
} catch {
|
|
91
|
+
// spacesDir unreadable or doesn't exist yet
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const allPaths: string[] = [];
|
|
95
|
+
for (const spaceId of spaceDirs) {
|
|
96
|
+
const base = path.join(spacesDir, spaceId);
|
|
97
|
+
allPaths.push(path.join(base, "inbox"));
|
|
98
|
+
allPaths.push(path.join(base, "outbox"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const [sizes, databaseBytes] = await Promise.all([
|
|
102
|
+
batchDirSizes(allPaths),
|
|
103
|
+
fileSizeBytes(dbPath),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const spaces: SpaceStorageInfo[] = spaceDirs.map((spaceId) => {
|
|
107
|
+
const base = path.join(spacesDir, spaceId);
|
|
108
|
+
const inboxBytes = sizes.get(path.join(base, "inbox")) ?? 0;
|
|
109
|
+
const outboxBytes = sizes.get(path.join(base, "outbox")) ?? 0;
|
|
110
|
+
return {
|
|
111
|
+
spaceId,
|
|
112
|
+
inboxBytes,
|
|
113
|
+
outboxBytes,
|
|
114
|
+
totalBytes: inboxBytes + outboxBytes,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return { disk, spaces, databaseBytes };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ensures the spaces directory exists. Call once at startup, not per-request.
|
|
123
|
+
*/
|
|
124
|
+
export function ensureSpacesDirExists(spacesDir: string): void {
|
|
125
|
+
mkdirSync(spacesDir, { recursive: true });
|
|
126
|
+
}
|