mop-agent 0.1.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.
Files changed (86) hide show
  1. package/README.md +177 -0
  2. package/apps/web/.env.example +18 -0
  3. package/apps/web/app/api/actions/[id]/approve/route.ts +15 -0
  4. package/apps/web/app/api/actions/[id]/deny/route.ts +15 -0
  5. package/apps/web/app/api/actions/route.ts +29 -0
  6. package/apps/web/app/api/auth/[...all]/route.ts +4 -0
  7. package/apps/web/app/api/chat/route.ts +50 -0
  8. package/apps/web/app/api/consolidate/route.ts +10 -0
  9. package/apps/web/app/api/graph/route.ts +34 -0
  10. package/apps/web/app/api/invites/route.ts +38 -0
  11. package/apps/web/app/api/link/code/route.ts +13 -0
  12. package/apps/web/app/api/link/pair/route.ts +41 -0
  13. package/apps/web/app/api/me/route.ts +11 -0
  14. package/apps/web/app/api/members/route.ts +16 -0
  15. package/apps/web/app/api/projects/[id]/memory/route.ts +12 -0
  16. package/apps/web/app/api/projects/[id]/state/route.ts +19 -0
  17. package/apps/web/app/api/projects/route.ts +21 -0
  18. package/apps/web/app/api/providers/route.ts +32 -0
  19. package/apps/web/app/api/semantic/route.ts +9 -0
  20. package/apps/web/app/api/setup/status/route.ts +6 -0
  21. package/apps/web/app/api/skills/route.ts +23 -0
  22. package/apps/web/app/brain/[projectId]/page.tsx +50 -0
  23. package/apps/web/app/brain/graph/page.tsx +54 -0
  24. package/apps/web/app/brain/page.tsx +167 -0
  25. package/apps/web/app/chat/[projectId]/page.tsx +113 -0
  26. package/apps/web/app/layout.tsx +24 -0
  27. package/apps/web/app/page.tsx +72 -0
  28. package/apps/web/app/settings/page.tsx +63 -0
  29. package/apps/web/app/setup/page.tsx +113 -0
  30. package/apps/web/app/team/page.tsx +86 -0
  31. package/apps/web/bin/mop-agent.mjs +85 -0
  32. package/apps/web/lib/auth-client.ts +5 -0
  33. package/apps/web/lib/auth.ts +86 -0
  34. package/apps/web/lib/authz.ts +23 -0
  35. package/apps/web/lib/brain/answer.ts +27 -0
  36. package/apps/web/lib/brain/approvals.ts +81 -0
  37. package/apps/web/lib/brain/broker.ts +98 -0
  38. package/apps/web/lib/brain/consolidate.ts +133 -0
  39. package/apps/web/lib/brain/mirror.ts +80 -0
  40. package/apps/web/lib/brain/scheduler.ts +30 -0
  41. package/apps/web/lib/brain/skills.ts +34 -0
  42. package/apps/web/lib/channels/binding.ts +26 -0
  43. package/apps/web/lib/channels/discord.ts +28 -0
  44. package/apps/web/lib/channels/handler.ts +44 -0
  45. package/apps/web/lib/channels/index.ts +18 -0
  46. package/apps/web/lib/channels/telegram.ts +18 -0
  47. package/apps/web/lib/crypto.ts +35 -0
  48. package/apps/web/lib/db/client.ts +34 -0
  49. package/apps/web/lib/db/migrate.ts +116 -0
  50. package/apps/web/lib/db/paths.ts +25 -0
  51. package/apps/web/lib/db/schema.ts +105 -0
  52. package/apps/web/lib/link/store.ts +89 -0
  53. package/apps/web/lib/memory/embed.ts +111 -0
  54. package/apps/web/lib/memory/local-embedder.ts +26 -0
  55. package/apps/web/lib/providers/anthropic.ts +23 -0
  56. package/apps/web/lib/providers/config.ts +55 -0
  57. package/apps/web/lib/providers/echo.ts +26 -0
  58. package/apps/web/lib/providers/index.ts +41 -0
  59. package/apps/web/lib/providers/openrouter.ts +24 -0
  60. package/apps/web/lib/providers/types.ts +14 -0
  61. package/apps/web/lib/ws/gateway.ts +113 -0
  62. package/apps/web/next-env.d.ts +6 -0
  63. package/apps/web/next.config.mjs +9 -0
  64. package/apps/web/package.json +44 -0
  65. package/apps/web/scripts/migrate.ts +12 -0
  66. package/apps/web/server.ts +27 -0
  67. package/apps/web/tsconfig.json +31 -0
  68. package/installer/bootstrap.mjs +161 -0
  69. package/installer/lib.mjs +196 -0
  70. package/installer/mop-agent.mjs +322 -0
  71. package/npm-shrinkwrap.json +5032 -0
  72. package/package.json +71 -0
  73. package/packages/flow-connector/bin/cli.mjs +67 -0
  74. package/packages/flow-connector/package.json +26 -0
  75. package/packages/flow-connector/src/exec.ts +81 -0
  76. package/packages/flow-connector/src/index.ts +17 -0
  77. package/packages/flow-connector/src/linkfile.ts +46 -0
  78. package/packages/flow-connector/src/pair.ts +66 -0
  79. package/packages/flow-connector/src/serve.ts +103 -0
  80. package/packages/flow-connector/src/snapshot.ts +94 -0
  81. package/packages/flow-connector/src/tools.ts +198 -0
  82. package/packages/flow-connector/tsconfig.json +10 -0
  83. package/packages/link-protocol/package.json +17 -0
  84. package/packages/link-protocol/src/index.ts +245 -0
  85. package/packages/link-protocol/tsconfig.json +10 -0
  86. package/tsconfig.base.json +18 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Link registry — DB-backed (Drizzle + SQLite). Same surface the gateway/routes
3
+ * already use. The link token is hashed (sha256) at rest; the plaintext is shown
4
+ * to FLOW exactly once at pairing.
5
+ */
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import { eq } from "drizzle-orm";
8
+ import type { Capabilities, ProjectManifest } from "@mop/link-protocol";
9
+ import { getDb } from "../db/client";
10
+ import { project, pairingCode } from "../db/schema";
11
+
12
+ export type ProjectRecord = {
13
+ id: string;
14
+ name: string;
15
+ mopFlowVersion: string | null;
16
+ capabilities: Capabilities;
17
+ status: "online" | "offline";
18
+ lastSeenAt: number | null;
19
+ };
20
+
21
+ export function sha256(s: string): string {
22
+ return createHash("sha256").update(s).digest("hex");
23
+ }
24
+
25
+ export function createPairingCode(ttlMs = 10 * 60_000): { code: string; expiresAt: number } {
26
+ const code = `${rand4()}-${rand4()}`;
27
+ const expiresAt = Date.now() + ttlMs;
28
+ getDb().insert(pairingCode).values({ code, expiresAt, usedAt: null, createdAt: Date.now() }).run();
29
+ return { code, expiresAt };
30
+ }
31
+
32
+ export function consumePairingCode(code: string): boolean {
33
+ const db = getDb();
34
+ const [row] = db.select().from(pairingCode).where(eq(pairingCode.code, code)).all();
35
+ if (!row || row.usedAt || row.expiresAt < Date.now()) return false;
36
+ db.update(pairingCode).set({ usedAt: Date.now() }).where(eq(pairingCode.code, code)).run();
37
+ return true;
38
+ }
39
+
40
+ export function registerProject(manifest: ProjectManifest): { linkToken: string } {
41
+ const linkToken = randomBytes(32).toString("hex");
42
+ const linkTokenHash = sha256(linkToken);
43
+ getDb()
44
+ .insert(project)
45
+ .values({
46
+ id: manifest.projectId,
47
+ name: manifest.name,
48
+ mopFlowVersion: manifest.mopFlowVersion,
49
+ linkTokenHash,
50
+ capabilities: manifest.capabilities,
51
+ status: "offline",
52
+ lastSeenAt: null,
53
+ createdAt: Date.now(),
54
+ })
55
+ .onConflictDoUpdate({
56
+ target: project.id,
57
+ set: { name: manifest.name, mopFlowVersion: manifest.mopFlowVersion, linkTokenHash, capabilities: manifest.capabilities },
58
+ })
59
+ .run();
60
+ return { linkToken };
61
+ }
62
+
63
+ export function findProjectByToken(token: string): ProjectRecord | undefined {
64
+ const [row] = getDb().select().from(project).where(eq(project.linkTokenHash, sha256(token))).all();
65
+ return row ? toRecord(row) : undefined;
66
+ }
67
+
68
+ export function setProjectStatus(id: string, status: ProjectRecord["status"]): void {
69
+ getDb().update(project).set({ status, lastSeenAt: Date.now() }).where(eq(project.id, id)).run();
70
+ }
71
+
72
+ export function listProjects(): ProjectRecord[] {
73
+ return getDb().select().from(project).all().map(toRecord);
74
+ }
75
+
76
+ function toRecord(row: typeof project.$inferSelect): ProjectRecord {
77
+ return {
78
+ id: row.id,
79
+ name: row.name,
80
+ mopFlowVersion: row.mopFlowVersion,
81
+ capabilities: row.capabilities,
82
+ status: row.status as "online" | "offline",
83
+ lastSeenAt: row.lastSeenAt,
84
+ };
85
+ }
86
+
87
+ function rand4(): string {
88
+ return randomBytes(2).toString("hex").toUpperCase();
89
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Embeddings + sqlite-vec index/search.
3
+ *
4
+ * Embedder is pluggable (PRD open decision #6). The default `dummyEmbedder` is a
5
+ * deterministic hashed bag-of-words — no network, no API key — so the whole loop
6
+ * runs offline. Text sharing words lands near in cosine space, which is enough to
7
+ * demonstrate recall plumbing. Swap in a provider/local model later via setEmbedder.
8
+ */
9
+ import { getSqlite, getDb } from "../db/client";
10
+ import { vecMap } from "../db/schema";
11
+
12
+ export const EMBED_DIM = 384;
13
+
14
+ export interface Embedder {
15
+ embed(text: string): Promise<number[]>;
16
+ }
17
+
18
+ export function dummyEmbedder(): Embedder {
19
+ return {
20
+ async embed(text: string): Promise<number[]> {
21
+ const v = new Array<number>(EMBED_DIM).fill(0);
22
+ for (const tok of text.toLowerCase().split(/\W+/).filter(Boolean)) {
23
+ let h = 2166136261;
24
+ for (let i = 0; i < tok.length; i += 1) {
25
+ h ^= tok.charCodeAt(i);
26
+ h = Math.imul(h, 16777619);
27
+ }
28
+ const idx = Math.abs(h) % EMBED_DIM;
29
+ v[idx] = (v[idx] ?? 0) + 1;
30
+ }
31
+ const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
32
+ return v.map((x) => x / norm);
33
+ },
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Default embedder: real local MiniLM (semantic), falling back to the dummy
39
+ * keyword-hash if the model can't load (offline / native issue) or when
40
+ * MOP_AGENT_EMBEDDER=dummy. Both produce EMBED_DIM (384) vectors, so the vec
41
+ * table is unaffected either way. The choice is made + cached on first embed.
42
+ */
43
+ function autoEmbedder(): Embedder {
44
+ let impl: Embedder | null = null;
45
+ return {
46
+ async embed(text: string): Promise<number[]> {
47
+ if (!impl) {
48
+ if (process.env.MOP_AGENT_EMBEDDER === "dummy") {
49
+ impl = dummyEmbedder();
50
+ } else {
51
+ try {
52
+ const { localEmbedder } = await import("./local-embedder");
53
+ const le = localEmbedder();
54
+ await le.embed("warmup"); // force model load now so failures fall back cleanly
55
+ impl = le;
56
+ console.log("[embed] local MiniLM embedder active");
57
+ } catch (e) {
58
+ console.warn(`[embed] local embedder unavailable, using dummy: ${e instanceof Error ? e.message : e}`);
59
+ impl = dummyEmbedder();
60
+ }
61
+ }
62
+ }
63
+ return impl.embed(text);
64
+ },
65
+ };
66
+ }
67
+
68
+ let _embedder: Embedder = autoEmbedder();
69
+ export function setEmbedder(e: Embedder): void {
70
+ _embedder = e;
71
+ }
72
+ export function embedText(text: string): Promise<number[]> {
73
+ return _embedder.embed(text);
74
+ }
75
+
76
+ export type RefType = "episodic" | "semantic" | "skill";
77
+
78
+ export async function embedAndIndex(refType: RefType, refId: string, text: string): Promise<void> {
79
+ const sqlite = getSqlite();
80
+ const vec = await embedText(text);
81
+ // Replace any existing vector for this ref (idempotent re-ingest).
82
+ const existing = sqlite
83
+ .prepare("SELECT rowid FROM vec_map WHERE ref_type = ? AND ref_id = ?")
84
+ .get(refType, refId) as { rowid: number } | undefined;
85
+ if (existing) {
86
+ sqlite.prepare("DELETE FROM vec_memory WHERE rowid = ?").run(existing.rowid);
87
+ sqlite.prepare("DELETE FROM vec_map WHERE rowid = ?").run(existing.rowid);
88
+ }
89
+ const info = sqlite
90
+ .prepare("INSERT INTO vec_memory(embedding) VALUES (vec_f32(?))")
91
+ .run(JSON.stringify(vec));
92
+ getDb().insert(vecMap).values({ rowid: Number(info.lastInsertRowid), refType, refId }).run();
93
+ }
94
+
95
+ export type SearchHit = { refType: RefType; refId: string; distance: number };
96
+
97
+ export async function semanticSearch(query: string, k = 8): Promise<SearchHit[]> {
98
+ const sqlite = getSqlite();
99
+ const qvec = await embedText(query);
100
+ // sqlite-vec KNN requires a `k = ?` constraint (LIMIT alone isn't honored with a JOIN).
101
+ const rows = sqlite
102
+ .prepare(
103
+ `SELECT m.ref_type AS refType, m.ref_id AS refId, v.distance AS distance
104
+ FROM vec_memory v
105
+ JOIN vec_map m ON m.rowid = v.rowid
106
+ WHERE v.embedding MATCH vec_f32(?) AND k = ?
107
+ ORDER BY v.distance`,
108
+ )
109
+ .all(JSON.stringify(qvec), k) as SearchHit[];
110
+ return rows;
111
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Local embedder — all-MiniLM-L6-v2 (384d) via transformers.js. No API key, runs
3
+ * on CPU. Model weights download once (~25MB) then cache. Lazy-loaded so it costs
4
+ * nothing until first use.
5
+ */
6
+ import type { Embedder } from "./embed";
7
+
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ let _extractor: Promise<any> | null = null;
10
+
11
+ export function localEmbedder(model = "Xenova/all-MiniLM-L6-v2"): Embedder {
12
+ return {
13
+ async embed(text: string): Promise<number[]> {
14
+ if (!_extractor) {
15
+ _extractor = (async () => {
16
+ const t = await import("@xenova/transformers");
17
+ t.env.allowLocalModels = false;
18
+ return t.pipeline("feature-extraction", model);
19
+ })();
20
+ }
21
+ const extractor = await _extractor;
22
+ const out = await extractor(text && text.trim() ? text : " ", { pooling: "mean", normalize: true });
23
+ return Array.from(out.data as Float32Array);
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,23 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type { ChatProvider, ChatOptions } from "./types";
3
+
4
+ export function anthropicProvider(apiKey: string, model = "claude-sonnet-4-6"): ChatProvider {
5
+ const client = new Anthropic({ apiKey });
6
+ return {
7
+ id: "anthropic",
8
+ model,
9
+ async *chat({ system, messages }: ChatOptions) {
10
+ const stream = client.messages.stream({
11
+ model,
12
+ max_tokens: 4096,
13
+ system,
14
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
15
+ });
16
+ for await (const ev of stream) {
17
+ if (ev.type === "content_block_delta" && ev.delta.type === "text_delta") {
18
+ yield ev.delta.text;
19
+ }
20
+ }
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,55 @@
1
+ /** Provider config store — encrypted API keys (AES-GCM) per owner. */
2
+ import { eq } from "drizzle-orm";
3
+ import { getDb } from "../db/client";
4
+ import { providerConfig } from "../db/schema";
5
+ import { decryptSecret, encryptSecret, keyHint } from "../crypto";
6
+
7
+ export type ProviderId = "anthropic" | "openrouter";
8
+
9
+ export function setProviderConfig(ownerId: string, input: { provider: ProviderId; apiKey: string; model?: string }): void {
10
+ const apiKeyEnc = encryptSecret(input.apiKey);
11
+ getDb()
12
+ .insert(providerConfig)
13
+ .values({ ownerId, provider: input.provider, apiKeyEnc, model: input.model ?? null, updatedAt: Date.now() })
14
+ .onConflictDoUpdate({
15
+ target: providerConfig.ownerId,
16
+ set: { provider: input.provider, apiKeyEnc, model: input.model ?? null, updatedAt: Date.now() },
17
+ })
18
+ .run();
19
+ }
20
+
21
+ /** Raw row (with encrypted key) for a given owner, or the first configured owner. */
22
+ export function getProviderConfigRow(ownerId?: string) {
23
+ const db = getDb();
24
+ if (ownerId) {
25
+ const [row] = db.select().from(providerConfig).where(eq(providerConfig.ownerId, ownerId)).all();
26
+ if (row) return row;
27
+ }
28
+ return db.select().from(providerConfig).all()[0];
29
+ }
30
+
31
+ export function getDecryptedKey(row: { apiKeyEnc: string }): string {
32
+ return decryptSecret(row.apiKeyEnc);
33
+ }
34
+
35
+ /** Safe view for the UI — never returns the key itself. */
36
+ export function getProviderConfigMasked(ownerId?: string): {
37
+ configured: boolean;
38
+ provider?: ProviderId;
39
+ model?: string | null;
40
+ keyHint?: string;
41
+ } {
42
+ const row = getProviderConfigRow(ownerId);
43
+ if (!row) return { configured: false };
44
+ let hint = "••••";
45
+ try {
46
+ hint = keyHint(decryptSecret(row.apiKeyEnc));
47
+ } catch {
48
+ /* secret rotated — can't decrypt */
49
+ }
50
+ return { configured: true, provider: row.provider as ProviderId, model: row.model, keyHint: hint };
51
+ }
52
+
53
+ export function clearProviderConfig(ownerId: string): void {
54
+ getDb().delete(providerConfig).where(eq(providerConfig.ownerId, ownerId)).run();
55
+ }
@@ -0,0 +1,26 @@
1
+ import type { ChatProvider, ChatOptions } from "./types";
2
+
3
+ /**
4
+ * Offline provider — no API key. Echoes a deterministic answer that proves the
5
+ * grounding pipeline (it reflects the injected context + question). Used as the
6
+ * default fallback and in smoke tests.
7
+ */
8
+ export function echoProvider(): ChatProvider {
9
+ return {
10
+ id: "echo",
11
+ model: "echo-1",
12
+ async *chat({ system, messages }: ChatOptions) {
13
+ const lastUser = [...messages].reverse().find((m) => m.role === "user")?.content ?? "";
14
+ const ctxLines = (system ?? "")
15
+ .split("\n")
16
+ .filter((l) => l.trim().startsWith("- "))
17
+ .slice(0, 5);
18
+ yield `🧠 (echo) You asked: "${lastUser}".\n\n`;
19
+ if (ctxLines.length) {
20
+ yield `Based on project memory I can see:\n${ctxLines.join("\n")}\n`;
21
+ } else {
22
+ yield `No project context was retrieved for this query.\n`;
23
+ }
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Provider resolution. Fasa 3: env-driven (ANTHROPIC_API_KEY / OPENROUTER_API_KEY),
3
+ * falls back to the offline echo provider so chat works with no keys.
4
+ * TODO Fasa 3b: per-owner encrypted provider_config (AES-GCM via MOP_AGENT_SECRET).
5
+ */
6
+ import type { ChatProvider } from "./types";
7
+ import { anthropicProvider } from "./anthropic";
8
+ import { openRouterProvider } from "./openrouter";
9
+ import { echoProvider } from "./echo";
10
+ import { getDecryptedKey, getProviderConfigRow } from "./config";
11
+
12
+ export type { ChatProvider, ChatOptions, Msg } from "./types";
13
+ export { anthropicProvider, openRouterProvider, echoProvider };
14
+
15
+ export function resolveProvider(ownerId?: string): ChatProvider {
16
+ // 1) DB config (encrypted key set via /settings) wins.
17
+ try {
18
+ const row = getProviderConfigRow(ownerId);
19
+ if (row) {
20
+ const apiKey = getDecryptedKey(row);
21
+ if (row.provider === "anthropic") return anthropicProvider(apiKey, row.model ?? undefined);
22
+ if (row.provider === "openrouter") return openRouterProvider(apiKey, row.model ?? undefined);
23
+ }
24
+ } catch {
25
+ /* DB not ready or secret rotated — fall through to env */
26
+ }
27
+
28
+ // 2) env fallback
29
+ const pref = process.env.MOP_AGENT_PROVIDER; // "anthropic" | "openrouter" | "echo"
30
+ if (pref === "anthropic" || (!pref && process.env.ANTHROPIC_API_KEY)) {
31
+ if (process.env.ANTHROPIC_API_KEY) {
32
+ return anthropicProvider(process.env.ANTHROPIC_API_KEY, process.env.MOP_AGENT_MODEL);
33
+ }
34
+ }
35
+ if (pref === "openrouter" || (!pref && process.env.OPENROUTER_API_KEY)) {
36
+ if (process.env.OPENROUTER_API_KEY) {
37
+ return openRouterProvider(process.env.OPENROUTER_API_KEY, process.env.MOP_AGENT_MODEL);
38
+ }
39
+ }
40
+ return echoProvider();
41
+ }
@@ -0,0 +1,24 @@
1
+ import OpenAI from "openai";
2
+ import type { ChatProvider, ChatOptions } from "./types";
3
+
4
+ /** OpenRouter is OpenAI-compatible — unlocks most other models in one adapter. */
5
+ export function openRouterProvider(apiKey: string, model = "anthropic/claude-sonnet-4.6"): ChatProvider {
6
+ const client = new OpenAI({ apiKey, baseURL: "https://openrouter.ai/api/v1" });
7
+ return {
8
+ id: "openrouter",
9
+ model,
10
+ async *chat({ system, messages }: ChatOptions) {
11
+ const stream = await client.chat.completions.create({
12
+ model,
13
+ stream: true,
14
+ messages: [
15
+ ...(system ? ([{ role: "system" as const, content: system }]) : []),
16
+ ...messages.map((m) => ({ role: m.role, content: m.content })),
17
+ ],
18
+ });
19
+ for await (const chunk of stream) {
20
+ yield chunk.choices[0]?.delta?.content ?? "";
21
+ }
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,14 @@
1
+ /** Provider-neutral chat interface. Adapters stream text deltas. */
2
+ export type Msg = { role: "user" | "assistant"; content: string };
3
+
4
+ export type ChatOptions = {
5
+ system?: string;
6
+ messages: Msg[];
7
+ };
8
+
9
+ export interface ChatProvider {
10
+ id: string;
11
+ model: string;
12
+ chat(opts: ChatOptions): AsyncIterable<string>;
13
+ embed?(text: string): Promise<number[]>;
14
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Reverse WSS gateway — accepts inbound connections FROM FLOW nodes.
3
+ *
4
+ * FLOW dials out (Authorization: Bearer <linkToken>); the AGENT authenticates the
5
+ * token, then uses the persistent socket to push snapshots up and send tool
6
+ * requests down. Lives on the custom Node server (see server.ts).
7
+ */
8
+ import type { Server } from "node:http";
9
+ import { WebSocketServer, type WebSocket } from "ws";
10
+ import { randomUUID } from "node:crypto";
11
+ import {
12
+ LINK_PROTOCOL_VERSION,
13
+ LINK_WS_PATH,
14
+ parseLinkMessage,
15
+ type LinkMessage,
16
+ type McpToolName,
17
+ } from "@mop/link-protocol";
18
+ import { findProjectByToken, setProjectStatus } from "../link/store";
19
+ import { ingestSnapshot } from "../brain/mirror";
20
+
21
+ type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void };
22
+
23
+ // Shared across the custom server (server.ts via tsx) and Next route bundles —
24
+ // without globalThis these would be separate module instances and callFlow()
25
+ // from an API route would never see the socket the gateway registered.
26
+ const g = globalThis as unknown as {
27
+ __mopLiveLinks?: Map<string, WebSocket>;
28
+ __mopPending?: Map<string, Pending>;
29
+ };
30
+ const liveLinks = (g.__mopLiveLinks ??= new Map<string, WebSocket>()); // projectId -> socket
31
+ const pending = (g.__mopPending ??= new Map<string, Pending>()); // req id -> resolver
32
+
33
+ export function attachGateway(server: Server): WebSocketServer {
34
+ const wss = new WebSocketServer({ server, path: LINK_WS_PATH });
35
+
36
+ wss.on("connection", (ws, req) => {
37
+ const token = (req.headers["authorization"] ?? "").toString().replace(/^Bearer\s+/i, "");
38
+ const proj = findProjectByToken(token);
39
+ if (!proj) {
40
+ ws.close(4001, "unauthorized");
41
+ return;
42
+ }
43
+
44
+ liveLinks.set(proj.id, ws);
45
+ setProjectStatus(proj.id, "online");
46
+ console.log(`[gateway] ${proj.id} online`);
47
+
48
+ ws.send(
49
+ JSON.stringify({
50
+ t: "hello",
51
+ capabilities: proj.capabilities,
52
+ serverTime: Date.now(),
53
+ protocolVersion: LINK_PROTOCOL_VERSION,
54
+ }),
55
+ );
56
+
57
+ ws.on("message", async (raw) => {
58
+ let msg: LinkMessage;
59
+ try {
60
+ msg = parseLinkMessage(raw.toString());
61
+ } catch {
62
+ return;
63
+ }
64
+ if (msg.t === "snapshot.push") {
65
+ try {
66
+ await ingestSnapshot(msg);
67
+ console.log(`[gateway] ${proj.id} snapshot: ${msg.memory.length} memories`);
68
+ } catch (e) {
69
+ console.error(`[gateway] ${proj.id} ingest failed:`, e);
70
+ }
71
+ } else if (msg.t === "res") {
72
+ const p = pending.get(msg.id);
73
+ if (p) {
74
+ pending.delete(msg.id);
75
+ msg.ok ? p.resolve(msg.data) : p.reject(new Error(msg.error ?? "flow_error"));
76
+ }
77
+ } else if (msg.t === "ping") {
78
+ ws.send(JSON.stringify({ t: "pong" }));
79
+ }
80
+ });
81
+
82
+ ws.on("close", () => {
83
+ liveLinks.delete(proj.id);
84
+ setProjectStatus(proj.id, "offline");
85
+ console.log(`[gateway] ${proj.id} offline`);
86
+ });
87
+ });
88
+
89
+ return wss;
90
+ }
91
+
92
+ /** AGENT -> FLOW: call a tool over the live link. Rejects if the project is offline. */
93
+ export function callFlow(
94
+ projectId: string,
95
+ tool: McpToolName,
96
+ args: Record<string, unknown>,
97
+ timeoutMs = 15_000,
98
+ ): Promise<unknown> {
99
+ const ws = liveLinks.get(projectId);
100
+ if (!ws) return Promise.reject(new Error("project_offline"));
101
+ const id = randomUUID();
102
+ return new Promise((resolve, reject) => {
103
+ pending.set(id, { resolve, reject });
104
+ ws.send(JSON.stringify({ t: "req", id, tool, args }));
105
+ setTimeout(() => {
106
+ if (pending.delete(id)) reject(new Error("flow_timeout"));
107
+ }, timeoutMs);
108
+ });
109
+ }
110
+
111
+ export function isOnline(projectId: string): boolean {
112
+ return liveLinks.has(projectId);
113
+ }
@@ -0,0 +1,6 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ /// <reference path="./.next/types/routes.d.ts" />
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,9 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ // The shared protocol package ships raw TS — let Next transpile it.
4
+ transpilePackages: ["@mop/link-protocol"],
5
+ // Native modules must not be bundled — require them at runtime instead.
6
+ serverExternalPackages: ["better-sqlite3", "sqlite-vec", "@xenova/transformers", "onnxruntime-node"],
7
+ };
8
+
9
+ export default nextConfig;
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@mop/web",
3
+ "version": "0.0.1",
4
+ "description": "MOP-AGENT web app — control plane, Brain, WS gateway, central agent.",
5
+ "private": true,
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "tsx server.ts",
9
+ "build": "next build",
10
+ "start": "cross-env NODE_ENV=production tsx server.ts",
11
+ "db:migrate": "tsx scripts/migrate.ts",
12
+ "cli": "tsx bin/mop-agent.mjs",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "@anthropic-ai/sdk": "^0.105.0",
17
+ "@mop/link-protocol": "*",
18
+ "@xenova/transformers": "^2.17.2",
19
+ "better-auth": "^1.6.20",
20
+ "better-sqlite3": "^12.11.1",
21
+ "croner": "^10.0.1",
22
+ "discord.js": "^14.26.4",
23
+ "drizzle-orm": "^0.45.2",
24
+ "grammy": "^1.44.0",
25
+ "next": "^15.1.0",
26
+ "openai": "^6.44.0",
27
+ "react": "^19.0.0",
28
+ "react-dom": "^19.0.0",
29
+ "reactflow": "^11.11.4",
30
+ "sqlite-vec": "^0.1.9",
31
+ "ws": "^8.18.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/better-sqlite3": "^7.6.13",
35
+ "@types/node": "^22.0.0",
36
+ "@types/react": "^19.0.0",
37
+ "@types/react-dom": "^19.0.0",
38
+ "@types/ws": "^8.5.12",
39
+ "cross-env": "^7.0.3",
40
+ "drizzle-kit": "^0.31.10",
41
+ "tsx": "^4.19.0",
42
+ "typescript": "^5.5.0"
43
+ }
44
+ }
@@ -0,0 +1,12 @@
1
+ /** Run all migrations (app tables + sqlite-vec + Better Auth). `npm run db:migrate`. */
2
+ import { runAllMigrations } from "../lib/db/migrate.js";
3
+
4
+ runAllMigrations()
5
+ .then(() => {
6
+ console.log("✅ migrations applied");
7
+ process.exit(0);
8
+ })
9
+ .catch((e) => {
10
+ console.error("❌ migration failed:", e);
11
+ process.exit(1);
12
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Custom Node server: Next.js request handler + the reverse WSS gateway on one port.
3
+ * Cross-platform (Windows + Linux). Run via `tsx server.ts`.
4
+ */
5
+ import { createServer } from "node:http";
6
+ import next from "next";
7
+ import { attachGateway } from "./lib/ws/gateway.js";
8
+ import { startChannels } from "./lib/channels/index.js";
9
+ import { startScheduler } from "./lib/brain/scheduler.js";
10
+
11
+ const dev = process.env.NODE_ENV !== "production";
12
+ const port = Number(process.env.PORT ?? 3000);
13
+
14
+ const app = next({ dev });
15
+ const handle = app.getRequestHandler();
16
+
17
+ app.prepare().then(() => {
18
+ const server = createServer((req, res) => handle(req, res));
19
+ attachGateway(server);
20
+ server.listen(port, async () => {
21
+ console.log(`MOP-AGENT → http://localhost:${port} (link ws: /link)`);
22
+ const channels = await startChannels();
23
+ if (channels.length) console.log(`channels started: ${channels.join(", ")}`);
24
+ const jobs = startScheduler();
25
+ if (jobs.length) console.log(`scheduler: ${jobs.join(", ")}`);
26
+ });
27
+ });
@@ -0,0 +1,31 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "preserve",
5
+ "noEmit": true,
6
+ "plugins": [
7
+ {
8
+ "name": "next"
9
+ }
10
+ ],
11
+ "types": [
12
+ "node"
13
+ ],
14
+ "paths": {
15
+ "@/*": [
16
+ "./*"
17
+ ]
18
+ },
19
+ "allowJs": true,
20
+ "incremental": true
21
+ },
22
+ "include": [
23
+ "next-env.d.ts",
24
+ "**/*.ts",
25
+ "**/*.tsx",
26
+ ".next/types/**/*.ts"
27
+ ],
28
+ "exclude": [
29
+ "node_modules"
30
+ ]
31
+ }