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.
Files changed (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/container/Dockerfile +127 -0
  4. package/container/Dockerfile.base +109 -0
  5. package/container/Dockerfile.power +17 -0
  6. package/container/agent-package.json +8 -0
  7. package/container/build.sh +54 -0
  8. package/docs/TODOS.md +147 -0
  9. package/docs/auth/dashboard.md +28 -0
  10. package/docs/auth/overview.md +109 -0
  11. package/docs/auth/whatsapp.md +173 -0
  12. package/docs/configuration.md +54 -0
  13. package/docs/container-lifecycle.md +349 -0
  14. package/docs/context-architecture.md +87 -0
  15. package/docs/deployment.md +199 -0
  16. package/docs/extensions.md +375 -0
  17. package/docs/graceful-shutdown.md +62 -0
  18. package/docs/kb-distillation.md +77 -0
  19. package/docs/media/overview.md +140 -0
  20. package/docs/media/whatsapp.md +171 -0
  21. package/docs/memory.md +137 -0
  22. package/docs/permissions.md +217 -0
  23. package/docs/pipeline.md +228 -0
  24. package/docs/prd-chat-memory.md +76 -0
  25. package/docs/prd-config-load.md +82 -0
  26. package/docs/rate-limiting.md +166 -0
  27. package/docs/scheduler.md +288 -0
  28. package/docs/setup-discord.md +100 -0
  29. package/docs/setup-slack.md +119 -0
  30. package/docs/setup-whatsapp.md +94 -0
  31. package/docs/subagents.md +166 -0
  32. package/docs/web-search.md +62 -0
  33. package/examples/extensions/README.md +12 -0
  34. package/examples/extensions/charts/index.ts +13 -0
  35. package/examples/extensions/charts/skill/SKILL.md +98 -0
  36. package/examples/extensions/gws/README.md +52 -0
  37. package/examples/extensions/gws/index.ts +106 -0
  38. package/examples/extensions/gws/skill/SKILL.md +57 -0
  39. package/examples/extensions/gws/skill/references/calendar.md +101 -0
  40. package/examples/extensions/gws/skill/references/docs.md +65 -0
  41. package/examples/extensions/gws/skill/references/drive.md +79 -0
  42. package/examples/extensions/gws/skill/references/gmail.md +85 -0
  43. package/examples/extensions/gws/skill/references/sheets.md +60 -0
  44. package/examples/extensions/napkin/index.ts +821 -0
  45. package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
  46. package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
  47. package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -0
  49. package/examples/extensions/pdf/index.ts +23 -0
  50. package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
  51. package/examples/extensions/pdf/skill/SKILL.md +314 -0
  52. package/examples/extensions/pdf/skill/forms.md +294 -0
  53. package/examples/extensions/pdf/skill/reference.md +612 -0
  54. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
  55. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
  56. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
  57. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
  58. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
  59. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
  60. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
  61. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
  62. package/examples/extensions/permission-guard/index.ts +65 -0
  63. package/examples/extensions/pinchtab/index.ts +199 -0
  64. package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
  65. package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
  66. package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
  67. package/examples/extensions/pinchtab/skill/references/api.md +297 -0
  68. package/examples/extensions/pinchtab/skill/references/env.md +45 -0
  69. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
  70. package/examples/extensions/tradestation/host/refresh.ts +102 -0
  71. package/examples/extensions/tradestation/index.ts +153 -0
  72. package/examples/extensions/tradestation/skill/SKILL.md +67 -0
  73. package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
  74. package/examples/extensions/voice-synth/index.ts +94 -0
  75. package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
  76. package/examples/extensions/voice-transcribe/index.ts +381 -0
  77. package/examples/extensions/voice-transcribe/requirements.txt +8 -0
  78. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
  79. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
  80. package/examples/extensions/web-search/index.ts +22 -0
  81. package/examples/extensions/web-search/skill/SKILL.md +114 -0
  82. package/examples/extensions/web-search/skill/references/apartments.md +178 -0
  83. package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
  84. package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
  85. package/examples/extensions/web-search/skill/references/flights.md +133 -0
  86. package/examples/extensions/web-search/skill/references/hotels.md +148 -0
  87. package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
  88. package/examples/extensions/yahoo-mail/cli/package.json +13 -0
  89. package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
  90. package/examples/extensions/yahoo-mail/index.ts +57 -0
  91. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
  92. package/package.json +106 -0
  93. package/resources/agents/explore.md +50 -0
  94. package/resources/agents/worker.md +24 -0
  95. package/resources/builtin-extensions.txt +3 -0
  96. package/resources/connection-env-vars.json +25 -0
  97. package/resources/extensions/.gitkeep +0 -0
  98. package/resources/pi-extensions/subagent/agents.ts +126 -0
  99. package/resources/pi-extensions/subagent/index.ts +964 -0
  100. package/resources/profiles/coding/AGENTS.md +43 -0
  101. package/resources/profiles/coding/mercury-profile.yaml +15 -0
  102. package/resources/profiles/general/AGENTS.md +31 -0
  103. package/resources/profiles/general/mercury-profile.yaml +15 -0
  104. package/resources/profiles/research/AGENTS.md +40 -0
  105. package/resources/profiles/research/mercury-profile.yaml +15 -0
  106. package/resources/skills/config/SKILL.md +25 -0
  107. package/resources/skills/context/SKILL.md +33 -0
  108. package/resources/skills/conversation-recap/SKILL.md +19 -0
  109. package/resources/skills/media/SKILL.md +27 -0
  110. package/resources/skills/mutes/SKILL.md +31 -0
  111. package/resources/skills/permissions/SKILL.md +19 -0
  112. package/resources/skills/preferences/SKILL.md +31 -0
  113. package/resources/skills/recall/SKILL.md +24 -0
  114. package/resources/skills/roles/SKILL.md +18 -0
  115. package/resources/skills/spaces/SKILL.md +18 -0
  116. package/resources/skills/tasks/SKILL.md +45 -0
  117. package/resources/templates/AGENTS.md +157 -0
  118. package/resources/templates/env.template +34 -0
  119. package/resources/templates/mercury.example.yaml +75 -0
  120. package/src/adapters/discord-native.ts +534 -0
  121. package/src/adapters/discord.ts +38 -0
  122. package/src/adapters/setup.ts +89 -0
  123. package/src/adapters/slack.ts +9 -0
  124. package/src/adapters/whatsapp-media.ts +337 -0
  125. package/src/adapters/whatsapp.ts +629 -0
  126. package/src/agent/api-socket.ts +127 -0
  127. package/src/agent/container-entry.ts +967 -0
  128. package/src/agent/container-error.ts +49 -0
  129. package/src/agent/container-runner.ts +1272 -0
  130. package/src/agent/model-capabilities-core.ts +23 -0
  131. package/src/agent/model-capabilities.ts +231 -0
  132. package/src/agent/pi-failure-class.ts +83 -0
  133. package/src/agent/pi-jsonl-parser.ts +306 -0
  134. package/src/agent/preferences-prompt.ts +20 -0
  135. package/src/agent/user-error-messages.ts +78 -0
  136. package/src/bridges/discord.ts +171 -0
  137. package/src/bridges/slack.ts +177 -0
  138. package/src/bridges/teams.ts +160 -0
  139. package/src/bridges/telegram.ts +571 -0
  140. package/src/bridges/whatsapp.ts +290 -0
  141. package/src/chat-shim.ts +259 -0
  142. package/src/cli/mercury.ts +2508 -0
  143. package/src/cli/mrctl-http.ts +27 -0
  144. package/src/cli/mrctl.ts +611 -0
  145. package/src/cli/whatsapp-auth.ts +260 -0
  146. package/src/config-file.ts +397 -0
  147. package/src/config-model-chain.ts +30 -0
  148. package/src/config.ts +316 -0
  149. package/src/core/api-types.ts +58 -0
  150. package/src/core/api.ts +105 -0
  151. package/src/core/commands.ts +76 -0
  152. package/src/core/conversation.ts +47 -0
  153. package/src/core/handler.ts +206 -0
  154. package/src/core/media.ts +200 -0
  155. package/src/core/mute-duration.ts +22 -0
  156. package/src/core/outbox.ts +76 -0
  157. package/src/core/permissions.ts +192 -0
  158. package/src/core/profiles.ts +245 -0
  159. package/src/core/rate-limiter.ts +127 -0
  160. package/src/core/router.ts +191 -0
  161. package/src/core/routes/chat.ts +172 -0
  162. package/src/core/routes/config-builtin.ts +107 -0
  163. package/src/core/routes/config.ts +81 -0
  164. package/src/core/routes/connections.ts +190 -0
  165. package/src/core/routes/console.ts +668 -0
  166. package/src/core/routes/control.ts +46 -0
  167. package/src/core/routes/conversations.ts +66 -0
  168. package/src/core/routes/dashboard.ts +2491 -0
  169. package/src/core/routes/extensions.ts +37 -0
  170. package/src/core/routes/index.ts +14 -0
  171. package/src/core/routes/media.ts +72 -0
  172. package/src/core/routes/messages.ts +37 -0
  173. package/src/core/routes/mutes.ts +89 -0
  174. package/src/core/routes/prefs.ts +95 -0
  175. package/src/core/routes/roles.ts +125 -0
  176. package/src/core/routes/spaces.ts +60 -0
  177. package/src/core/routes/storage.ts +126 -0
  178. package/src/core/routes/tasks.ts +189 -0
  179. package/src/core/routes/tradestation.ts +268 -0
  180. package/src/core/routes/tts.ts +51 -0
  181. package/src/core/runtime.ts +1140 -0
  182. package/src/core/space-queue.ts +103 -0
  183. package/src/core/storage-cleanup.ts +140 -0
  184. package/src/core/storage-guard.ts +24 -0
  185. package/src/core/task-scheduler.ts +132 -0
  186. package/src/core/telegram-format.ts +178 -0
  187. package/src/core/trigger.ts +142 -0
  188. package/src/dashboard/index.html +729 -0
  189. package/src/dashboard/tokens.css +53 -0
  190. package/src/extensions/api.ts +252 -0
  191. package/src/extensions/catalog.ts +117 -0
  192. package/src/extensions/config-registry.ts +83 -0
  193. package/src/extensions/context.ts +36 -0
  194. package/src/extensions/hooks.ts +156 -0
  195. package/src/extensions/image-builder.ts +617 -0
  196. package/src/extensions/installer.ts +306 -0
  197. package/src/extensions/jobs.ts +122 -0
  198. package/src/extensions/loader.ts +271 -0
  199. package/src/extensions/permission-guard.ts +52 -0
  200. package/src/extensions/reserved.ts +28 -0
  201. package/src/extensions/skills.ts +123 -0
  202. package/src/extensions/types.ts +462 -0
  203. package/src/logger.ts +174 -0
  204. package/src/main.ts +586 -0
  205. package/src/server.ts +391 -0
  206. package/src/storage/db.ts +1624 -0
  207. package/src/storage/memory.ts +45 -0
  208. package/src/storage/pi-auth.ts +95 -0
  209. package/src/text/markdown.ts +117 -0
  210. package/src/text/rtl.ts +38 -0
  211. package/src/tradestation/host-api.ts +77 -0
  212. package/src/tradestation/pending-orders.ts +69 -0
  213. package/src/tts/azure.ts +52 -0
  214. package/src/tts/google.ts +128 -0
  215. package/src/tts/index.ts +8 -0
  216. package/src/tts/language.ts +20 -0
  217. package/src/tts/synthesize.ts +133 -0
  218. 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
+ }