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
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "mop-agent",
3
+ "version": "0.1.0",
4
+ "description": "Self-hosted AI brain and control plane for MOP-FLOW projects, installed with npx mop-agent.",
5
+ "author": "BURHANDEV ENTERPRISE",
6
+ "license": "UNLICENSED",
7
+ "keywords": [
8
+ "ai",
9
+ "agent",
10
+ "memory",
11
+ "self-hosted",
12
+ "mop-flow"
13
+ ],
14
+ "homepage": "https://github.com/BURHANDEV-ENTERPRISE/mop-agent#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/BURHANDEV-ENTERPRISE/mop-agent/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/BURHANDEV-ENTERPRISE/mop-agent.git"
21
+ },
22
+ "type": "module",
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "workspaces": [
27
+ "packages/*",
28
+ "apps/*"
29
+ ],
30
+ "bin": {
31
+ "mop-agent": "installer/bootstrap.mjs"
32
+ },
33
+ "files": [
34
+ "apps/web/app",
35
+ "apps/web/bin",
36
+ "apps/web/lib",
37
+ "apps/web/scripts",
38
+ "apps/web/.env.example",
39
+ "apps/web/next-env.d.ts",
40
+ "apps/web/next.config.mjs",
41
+ "apps/web/package.json",
42
+ "apps/web/server.ts",
43
+ "apps/web/tsconfig.json",
44
+ "packages/flow-connector/bin",
45
+ "packages/flow-connector/src",
46
+ "packages/flow-connector/package.json",
47
+ "packages/flow-connector/tsconfig.json",
48
+ "packages/link-protocol/src",
49
+ "packages/link-protocol/package.json",
50
+ "packages/link-protocol/tsconfig.json",
51
+ "installer/bootstrap.mjs",
52
+ "installer/lib.mjs",
53
+ "installer/mop-agent.mjs",
54
+ "npm-shrinkwrap.json",
55
+ "tsconfig.base.json"
56
+ ],
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "scripts": {
61
+ "build": "npm run build --workspaces --if-present",
62
+ "typecheck": "npm run typecheck --workspaces --if-present",
63
+ "dev:web": "npm run dev --workspace @mop/web",
64
+ "dev:flow": "npm run dev --workspace @mop/flow-connector",
65
+ "installer": "node installer/mop-agent.mjs",
66
+ "bootstrap:self-test": "node installer/bootstrap.mjs --self-test",
67
+ "release:check": "npm run typecheck && npx tsx scripts/smoke-installer.mts && npm run bootstrap:self-test",
68
+ "prepublishOnly": "npm run release:check",
69
+ "clean": "node -e \"console.log('clean: remove dist + node_modules manually if needed')\""
70
+ }
71
+ }
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dev CLI for the FLOW connector. Cross-platform (Node only).
4
+ *
5
+ * mop-flow-dev link --url <agentUrl> --code <code> --project <id> [--root <dir>]
6
+ * mop-flow-dev serve [--root <dir>]
7
+ *
8
+ * In production this folds into the published `mop-flow` binary (1.3.0).
9
+ */
10
+ import { pair } from "../src/pair.js";
11
+ import { serve } from "../src/serve.js";
12
+
13
+ function parseArgs(argv) {
14
+ const out = { _: [] };
15
+ for (let i = 0; i < argv.length; i += 1) {
16
+ const a = argv[i];
17
+ if (!a.startsWith("--")) {
18
+ out._.push(a);
19
+ continue;
20
+ }
21
+ const key = a.slice(2);
22
+ const next = argv[i + 1];
23
+ if (!next || next.startsWith("--")) {
24
+ out[key] = true;
25
+ } else {
26
+ out[key] = next;
27
+ i += 1;
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+
33
+ async function main() {
34
+ const [cmd, ...rest] = process.argv.slice(2);
35
+ const args = parseArgs(rest);
36
+ const root = typeof args.root === "string" ? args.root : process.cwd();
37
+
38
+ if (cmd === "link") {
39
+ if (!args.url || !args.code || !args.project) {
40
+ console.error("usage: mop-flow-dev link --url <agentUrl> --code <code> --project <id> [--root <dir>]");
41
+ process.exit(1);
42
+ }
43
+ const link = await pair({
44
+ projectRoot: root,
45
+ agentUrl: String(args.url),
46
+ code: String(args.code),
47
+ projectId: String(args.project),
48
+ name: typeof args.name === "string" ? args.name : undefined,
49
+ });
50
+ console.log(`linked: project=${link.projectId} → ${link.agentUrl}`);
51
+ console.log(`wrote ${root}/.MOP/link.json (token stored, gitignored)`);
52
+ return;
53
+ }
54
+
55
+ if (cmd === "serve") {
56
+ await serve({ projectRoot: root });
57
+ return;
58
+ }
59
+
60
+ console.error("commands: link | serve");
61
+ process.exit(1);
62
+ }
63
+
64
+ main().catch((e) => {
65
+ console.error(e instanceof Error ? e.message : e);
66
+ process.exit(1);
67
+ });
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@mop/flow-connector",
3
+ "version": "0.0.1",
4
+ "description": "MOP-FLOW vNext connector — dials out to MOP-AGENT, serves project context, feeds the Brain. (Dev home; merges into published mop-flow later.)",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "bin": {
9
+ "mop-flow-dev": "./bin/cli.mjs"
10
+ },
11
+ "scripts": {
12
+ "typecheck": "tsc --noEmit",
13
+ "dev": "tsx watch src/serve.ts",
14
+ "link": "tsx bin/cli.mjs link",
15
+ "serve": "tsx bin/cli.mjs serve"
16
+ },
17
+ "dependencies": {
18
+ "@mop/link-protocol": "*",
19
+ "ws": "^8.18.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/ws": "^8.5.12",
23
+ "tsx": "^4.19.0",
24
+ "typescript": "^5.5.0"
25
+ }
26
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Execution backends for capability-gated run_shell (Fasa 6).
3
+ *
4
+ * host — run on the user's machine (default), cwd = project root.
5
+ * docker — sandbox in a container; project mounted at /work; network "none" by default.
6
+ * ssh — run on a remote build box.
7
+ *
8
+ * Only reached when the runShell/editCode capability is enabled (tools.ts guards),
9
+ * so this is opt-in. Each command has a hard timeout.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { platform } from "node:os";
13
+ import type { ExecutionPolicy } from "@mop/link-protocol";
14
+
15
+ export type ExecResult = { stdout: string; stderr: string; code: number | null };
16
+
17
+ function run(cmd: string, args: string[], timeoutMs: number): Promise<ExecResult> {
18
+ return new Promise((resolve) => {
19
+ const child = spawn(cmd, args, { windowsHide: true });
20
+ let stdout = "";
21
+ let stderr = "";
22
+ const timer = setTimeout(() => {
23
+ stderr += `\n[timeout after ${timeoutMs}ms]`;
24
+ child.kill("SIGKILL");
25
+ }, timeoutMs);
26
+ child.stdout.on("data", (d) => (stdout += d.toString()));
27
+ child.stderr.on("data", (d) => (stderr += d.toString()));
28
+ child.on("error", (e) => {
29
+ clearTimeout(timer);
30
+ resolve({ stdout, stderr: stderr + String(e.message), code: 127 });
31
+ });
32
+ child.on("close", (code) => {
33
+ clearTimeout(timer);
34
+ resolve({ stdout: stdout.slice(0, 100_000), stderr: stderr.slice(0, 20_000), code });
35
+ });
36
+ });
37
+ }
38
+
39
+ export async function runShell(command: string, projectRoot: string, policy: ExecutionPolicy): Promise<ExecResult> {
40
+ const timeoutMs = policy.timeoutMs ?? 60_000;
41
+
42
+ if (policy.backend === "docker") {
43
+ const image = policy.docker?.image ?? "node:20-slim";
44
+ const network = policy.docker?.network ?? "none";
45
+ return run(
46
+ "docker",
47
+ ["run", "--rm", "--network", network, "-v", `${projectRoot}:/work`, "-w", "/work", image, "sh", "-lc", command],
48
+ timeoutMs,
49
+ );
50
+ }
51
+
52
+ if (policy.backend === "ssh") {
53
+ if (!policy.ssh) return { stdout: "", stderr: "ssh backend not configured", code: 1 };
54
+ const remote = `${policy.ssh.user}@${policy.ssh.host}`;
55
+ const remoteCmd = policy.ssh.cwd ? `cd ${policy.ssh.cwd} && ${command}` : command;
56
+ return run("ssh", [remote, remoteCmd], timeoutMs);
57
+ }
58
+
59
+ // host (default)
60
+ const shell = platform() === "win32" ? "cmd" : "sh";
61
+ const flag = platform() === "win32" ? "/c" : "-lc";
62
+ return new Promise((resolve) => {
63
+ const child = spawn(shell, [flag, command], { cwd: projectRoot, windowsHide: true });
64
+ let stdout = "";
65
+ let stderr = "";
66
+ const timer = setTimeout(() => {
67
+ stderr += `\n[timeout after ${timeoutMs}ms]`;
68
+ child.kill("SIGKILL");
69
+ }, timeoutMs);
70
+ child.stdout.on("data", (d) => (stdout += d.toString()));
71
+ child.stderr.on("data", (d) => (stderr += d.toString()));
72
+ child.on("error", (e) => {
73
+ clearTimeout(timer);
74
+ resolve({ stdout, stderr: stderr + String(e.message), code: 127 });
75
+ });
76
+ child.on("close", (code) => {
77
+ clearTimeout(timer);
78
+ resolve({ stdout: stdout.slice(0, 100_000), stderr: stderr.slice(0, 20_000), code });
79
+ });
80
+ });
81
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @mop/flow-connector — MOP-FLOW vNext connector.
3
+ *
4
+ * Public surface for embedding the connector (e.g. inside the published mop-flow,
5
+ * or for tests that drive the link end-to-end).
6
+ */
7
+ export { pair, type PairOptions } from "./pair.js";
8
+ export { serve, type ServeOptions } from "./serve.js";
9
+ export { buildSnapshot, redactSensitive } from "./snapshot.js";
10
+ export { handleToolRequest, CapabilityError, type ToolContext } from "./tools.js";
11
+ export {
12
+ readLink,
13
+ writeLink,
14
+ isLinked,
15
+ linkPath,
16
+ type LinkFile,
17
+ } from "./linkfile.js";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * .MOP/link.json read/write — the per-project link credential + config.
3
+ * The bearer token lives ONLY here (gitignored, chmod 600 on POSIX).
4
+ */
5
+ import { readFile, writeFile, chmod, mkdir } from "node:fs/promises";
6
+ import { existsSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import type { Capabilities, ExecutionPolicy } from "@mop/link-protocol";
9
+
10
+ export type LinkFile = {
11
+ schemaVersion: "1.0";
12
+ agentUrl: string;
13
+ wsUrl: string;
14
+ projectId: string;
15
+ linkToken: string;
16
+ capabilities: Capabilities;
17
+ /** Only used when runShell/editCode capabilities are enabled. */
18
+ execution?: ExecutionPolicy;
19
+ lastSyncAt: string | null;
20
+ autoSync: boolean;
21
+ };
22
+
23
+ export function linkPath(projectRoot: string): string {
24
+ return join(projectRoot, ".MOP", "link.json");
25
+ }
26
+
27
+ export function isLinked(projectRoot: string): boolean {
28
+ return existsSync(linkPath(projectRoot));
29
+ }
30
+
31
+ export async function readLink(projectRoot: string): Promise<LinkFile> {
32
+ const raw = await readFile(linkPath(projectRoot), "utf8");
33
+ return JSON.parse(raw) as LinkFile;
34
+ }
35
+
36
+ export async function writeLink(projectRoot: string, link: LinkFile): Promise<void> {
37
+ const p = linkPath(projectRoot);
38
+ await mkdir(dirname(p), { recursive: true });
39
+ await writeFile(p, JSON.stringify(link, null, 2), "utf8");
40
+ // Best-effort restrictive perms on POSIX; on Windows this is a no-op.
41
+ try {
42
+ await chmod(p, 0o600);
43
+ } catch {
44
+ /* Windows / unsupported FS — file is gitignored regardless */
45
+ }
46
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Pairing — FLOW -> AGENT (HTTP), before the WSS link opens.
3
+ * Posts the project manifest + one-time pairing code, receives a link token.
4
+ */
5
+ import { platform } from "node:os";
6
+ import {
7
+ DEFAULT_CAPABILITIES,
8
+ type Capabilities,
9
+ type PairRequest,
10
+ type PairResponse,
11
+ type ProjectManifest,
12
+ } from "@mop/link-protocol";
13
+ import { writeLink, type LinkFile } from "./linkfile.js";
14
+
15
+ export type PairOptions = {
16
+ projectRoot: string;
17
+ agentUrl: string;
18
+ code: string;
19
+ projectId: string;
20
+ name?: string;
21
+ mopFlowVersion?: string;
22
+ capabilities?: Capabilities;
23
+ };
24
+
25
+ function deriveWsUrl(agentUrl: string): string {
26
+ const u = new URL(agentUrl);
27
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
28
+ u.pathname = "/link";
29
+ return u.toString();
30
+ }
31
+
32
+ export async function pair(opts: PairOptions): Promise<LinkFile> {
33
+ const manifest: ProjectManifest = {
34
+ projectId: opts.projectId,
35
+ name: opts.name ?? opts.projectId,
36
+ mopFlowVersion: opts.mopFlowVersion ?? "1.3.0-dev",
37
+ platform: platform(),
38
+ capabilities: opts.capabilities ?? DEFAULT_CAPABILITIES,
39
+ };
40
+
41
+ const body: PairRequest = { code: opts.code, manifest };
42
+ const res = await fetch(new URL("/api/link/pair", opts.agentUrl), {
43
+ method: "POST",
44
+ headers: { "content-type": "application/json" },
45
+ body: JSON.stringify(body),
46
+ });
47
+
48
+ if (!res.ok) {
49
+ const text = await res.text().catch(() => "");
50
+ throw new Error(`pair_failed:${res.status}:${text}`);
51
+ }
52
+
53
+ const out = (await res.json()) as PairResponse;
54
+ const link: LinkFile = {
55
+ schemaVersion: "1.0",
56
+ agentUrl: opts.agentUrl,
57
+ wsUrl: out.wsUrl ?? deriveWsUrl(opts.agentUrl),
58
+ projectId: out.projectId,
59
+ linkToken: out.linkToken,
60
+ capabilities: manifest.capabilities,
61
+ lastSyncAt: null,
62
+ autoSync: true,
63
+ };
64
+ await writeLink(opts.projectRoot, link);
65
+ return link;
66
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Reverse WSS client — FLOW dials OUT to AGENT and keeps the link open.
3
+ *
4
+ * Outbound connection traverses NAT/firewall, so the AGENT can live on a VPS while
5
+ * the project stays on the user's PC. Auto-reconnect with exponential backoff
6
+ * handles laptop sleep / network changes (Windows + Linux).
7
+ */
8
+ import WebSocket from "ws";
9
+ import {
10
+ parseLinkMessage,
11
+ type Capabilities,
12
+ type LinkMessage,
13
+ type McpToolName,
14
+ } from "@mop/link-protocol";
15
+ import { readLink, writeLink } from "./linkfile.js";
16
+ import { buildSnapshot } from "./snapshot.js";
17
+ import { handleToolRequest, type ToolContext } from "./tools.js";
18
+
19
+ export type ServeOptions = {
20
+ projectRoot: string;
21
+ /** optional hook into the real .MOP session model (v1.2.0) */
22
+ hasValidSession?: ToolContext["hasValidSession"];
23
+ onStatus?: (s: string) => void;
24
+ };
25
+
26
+ const MAX_BACKOFF = 30_000;
27
+
28
+ export async function serve(opts: ServeOptions): Promise<void> {
29
+ const log = opts.onStatus ?? ((s: string) => console.log(`[mop-flow] ${s}`));
30
+ let backoff = 1_000;
31
+ let stopped = false;
32
+
33
+ const connect = async (): Promise<void> => {
34
+ const link = await readLink(opts.projectRoot);
35
+ const ctx: ToolContext = {
36
+ projectRoot: opts.projectRoot,
37
+ capabilities: link.capabilities,
38
+ hasValidSession: opts.hasValidSession,
39
+ execution: link.execution,
40
+ };
41
+
42
+ const ws = new WebSocket(link.wsUrl, {
43
+ headers: { Authorization: `Bearer ${link.linkToken}` },
44
+ });
45
+
46
+ ws.on("open", async () => {
47
+ backoff = 1_000;
48
+ const snap = await buildSnapshot(opts.projectRoot, link.projectId);
49
+ ws.send(JSON.stringify(snap));
50
+ link.lastSyncAt = new Date().toISOString();
51
+ await writeLink(opts.projectRoot, link);
52
+ log(`linked → ${link.wsUrl} · snapshot pushed (${snap.memory.length} memories)`);
53
+ });
54
+
55
+ ws.on("message", async (raw) => {
56
+ let msg: LinkMessage;
57
+ try {
58
+ msg = parseLinkMessage(raw.toString());
59
+ } catch {
60
+ return;
61
+ }
62
+ if (msg.t === "req") {
63
+ try {
64
+ const data = await handleToolRequest(msg.tool as McpToolName, msg.args, ctx);
65
+ ws.send(JSON.stringify({ t: "res", id: msg.id, ok: true, data }));
66
+ } catch (e) {
67
+ ws.send(JSON.stringify({ t: "res", id: msg.id, ok: false, error: errMsg(e) }));
68
+ }
69
+ } else if (msg.t === "ping") {
70
+ ws.send(JSON.stringify({ t: "pong" }));
71
+ } else if (msg.t === "hello") {
72
+ log(`hello from AGENT (caps: ${capSummary(msg.capabilities)})`);
73
+ }
74
+ });
75
+
76
+ ws.on("close", () => {
77
+ if (stopped) return;
78
+ log(`link closed · retry in ${backoff}ms`);
79
+ setTimeout(connect, backoff);
80
+ backoff = Math.min(backoff * 2, MAX_BACKOFF);
81
+ });
82
+
83
+ ws.on("error", (e) => log(`ws error: ${errMsg(e)}`));
84
+ };
85
+
86
+ process.on("SIGINT", () => {
87
+ stopped = true;
88
+ process.exit(0);
89
+ });
90
+
91
+ await connect();
92
+ }
93
+
94
+ function errMsg(e: unknown): string {
95
+ return e instanceof Error ? e.message : String(e);
96
+ }
97
+
98
+ function capSummary(caps: Capabilities): string {
99
+ return Object.entries(caps)
100
+ .filter(([, v]) => v)
101
+ .map(([k]) => k)
102
+ .join(",");
103
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Snapshot builder — gathers project state + memory + artifacts to push to the Brain.
3
+ *
4
+ * Privacy at source: secrets (tokens, password hashes) are stripped BEFORE the
5
+ * snapshot leaves the machine. The Brain stores experience, never credentials.
6
+ */
7
+ import { readFile, readdir, stat } from "node:fs/promises";
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { ArtifactRef, MemoryEntry, SnapshotPushMessage } from "@mop/link-protocol";
11
+
12
+ const SENSITIVE_KEY = /(token|secret|password|passwordhash|apikey|api_key)/i;
13
+
14
+ /** Recursively strip values whose key looks sensitive. */
15
+ export function redactSensitive<T>(value: T): T {
16
+ if (Array.isArray(value)) {
17
+ return value.map((v) => redactSensitive(v)) as unknown as T;
18
+ }
19
+ if (value && typeof value === "object") {
20
+ const out: Record<string, unknown> = {};
21
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
22
+ if (SENSITIVE_KEY.test(k)) continue;
23
+ out[k] = redactSensitive(v);
24
+ }
25
+ return out as unknown as T;
26
+ }
27
+ return value;
28
+ }
29
+
30
+ async function readState(projectRoot: string): Promise<unknown> {
31
+ const p = join(projectRoot, ".MOP", "STATE.json");
32
+ if (!existsSync(p)) return {};
33
+ return JSON.parse(await readFile(p, "utf8"));
34
+ }
35
+
36
+ /** Read recent episodic entries from monthly JSONL files (.MOP/memory/YYYY-MM.jsonl). */
37
+ async function readMemory(projectRoot: string, limit = 200): Promise<MemoryEntry[]> {
38
+ const dir = join(projectRoot, ".MOP", "memory");
39
+ if (!existsSync(dir)) return [];
40
+ const files = (await readdir(dir))
41
+ .filter((f) => f.endsWith(".jsonl"))
42
+ .sort()
43
+ .reverse();
44
+
45
+ const entries: MemoryEntry[] = [];
46
+ for (const f of files) {
47
+ const raw = await readFile(join(dir, f), "utf8");
48
+ for (const line of raw.split("\n")) {
49
+ const trimmed = line.trim();
50
+ if (!trimmed) continue;
51
+ try {
52
+ entries.push(JSON.parse(trimmed) as MemoryEntry);
53
+ } catch {
54
+ /* skip malformed line */
55
+ }
56
+ }
57
+ if (entries.length >= limit) break;
58
+ }
59
+ return entries.slice(0, limit);
60
+ }
61
+
62
+ async function listArtifacts(projectRoot: string): Promise<ArtifactRef[]> {
63
+ const dir = join(projectRoot, ".MOP", "artifacts");
64
+ if (!existsSync(dir)) return [];
65
+ const out: ArtifactRef[] = [];
66
+ const walk = async (d: string, base: string): Promise<void> => {
67
+ for (const name of await readdir(d)) {
68
+ const full = join(d, name);
69
+ const s = await stat(full);
70
+ if (s.isDirectory()) await walk(full, join(base, name));
71
+ else out.push({ path: join(base, name), updatedAt: s.mtimeMs });
72
+ }
73
+ };
74
+ await walk(dir, "");
75
+ return out;
76
+ }
77
+
78
+ export async function buildSnapshot(
79
+ projectRoot: string,
80
+ projectId: string,
81
+ ): Promise<SnapshotPushMessage> {
82
+ const [state, memory, artifacts] = await Promise.all([
83
+ readState(projectRoot),
84
+ readMemory(projectRoot),
85
+ listArtifacts(projectRoot),
86
+ ]);
87
+ return {
88
+ t: "snapshot.push",
89
+ projectId,
90
+ state: redactSensitive(state),
91
+ memory,
92
+ artifacts,
93
+ };
94
+ }