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.
- package/README.md +177 -0
- package/apps/web/.env.example +18 -0
- package/apps/web/app/api/actions/[id]/approve/route.ts +15 -0
- package/apps/web/app/api/actions/[id]/deny/route.ts +15 -0
- package/apps/web/app/api/actions/route.ts +29 -0
- package/apps/web/app/api/auth/[...all]/route.ts +4 -0
- package/apps/web/app/api/chat/route.ts +50 -0
- package/apps/web/app/api/consolidate/route.ts +10 -0
- package/apps/web/app/api/graph/route.ts +34 -0
- package/apps/web/app/api/invites/route.ts +38 -0
- package/apps/web/app/api/link/code/route.ts +13 -0
- package/apps/web/app/api/link/pair/route.ts +41 -0
- package/apps/web/app/api/me/route.ts +11 -0
- package/apps/web/app/api/members/route.ts +16 -0
- package/apps/web/app/api/projects/[id]/memory/route.ts +12 -0
- package/apps/web/app/api/projects/[id]/state/route.ts +19 -0
- package/apps/web/app/api/projects/route.ts +21 -0
- package/apps/web/app/api/providers/route.ts +32 -0
- package/apps/web/app/api/semantic/route.ts +9 -0
- package/apps/web/app/api/setup/status/route.ts +6 -0
- package/apps/web/app/api/skills/route.ts +23 -0
- package/apps/web/app/brain/[projectId]/page.tsx +50 -0
- package/apps/web/app/brain/graph/page.tsx +54 -0
- package/apps/web/app/brain/page.tsx +167 -0
- package/apps/web/app/chat/[projectId]/page.tsx +113 -0
- package/apps/web/app/layout.tsx +24 -0
- package/apps/web/app/page.tsx +72 -0
- package/apps/web/app/settings/page.tsx +63 -0
- package/apps/web/app/setup/page.tsx +113 -0
- package/apps/web/app/team/page.tsx +86 -0
- package/apps/web/bin/mop-agent.mjs +85 -0
- package/apps/web/lib/auth-client.ts +5 -0
- package/apps/web/lib/auth.ts +86 -0
- package/apps/web/lib/authz.ts +23 -0
- package/apps/web/lib/brain/answer.ts +27 -0
- package/apps/web/lib/brain/approvals.ts +81 -0
- package/apps/web/lib/brain/broker.ts +98 -0
- package/apps/web/lib/brain/consolidate.ts +133 -0
- package/apps/web/lib/brain/mirror.ts +80 -0
- package/apps/web/lib/brain/scheduler.ts +30 -0
- package/apps/web/lib/brain/skills.ts +34 -0
- package/apps/web/lib/channels/binding.ts +26 -0
- package/apps/web/lib/channels/discord.ts +28 -0
- package/apps/web/lib/channels/handler.ts +44 -0
- package/apps/web/lib/channels/index.ts +18 -0
- package/apps/web/lib/channels/telegram.ts +18 -0
- package/apps/web/lib/crypto.ts +35 -0
- package/apps/web/lib/db/client.ts +34 -0
- package/apps/web/lib/db/migrate.ts +116 -0
- package/apps/web/lib/db/paths.ts +25 -0
- package/apps/web/lib/db/schema.ts +105 -0
- package/apps/web/lib/link/store.ts +89 -0
- package/apps/web/lib/memory/embed.ts +111 -0
- package/apps/web/lib/memory/local-embedder.ts +26 -0
- package/apps/web/lib/providers/anthropic.ts +23 -0
- package/apps/web/lib/providers/config.ts +55 -0
- package/apps/web/lib/providers/echo.ts +26 -0
- package/apps/web/lib/providers/index.ts +41 -0
- package/apps/web/lib/providers/openrouter.ts +24 -0
- package/apps/web/lib/providers/types.ts +14 -0
- package/apps/web/lib/ws/gateway.ts +113 -0
- package/apps/web/next-env.d.ts +6 -0
- package/apps/web/next.config.mjs +9 -0
- package/apps/web/package.json +44 -0
- package/apps/web/scripts/migrate.ts +12 -0
- package/apps/web/server.ts +27 -0
- package/apps/web/tsconfig.json +31 -0
- package/installer/bootstrap.mjs +161 -0
- package/installer/lib.mjs +196 -0
- package/installer/mop-agent.mjs +322 -0
- package/npm-shrinkwrap.json +5032 -0
- package/package.json +71 -0
- package/packages/flow-connector/bin/cli.mjs +67 -0
- package/packages/flow-connector/package.json +26 -0
- package/packages/flow-connector/src/exec.ts +81 -0
- package/packages/flow-connector/src/index.ts +17 -0
- package/packages/flow-connector/src/linkfile.ts +46 -0
- package/packages/flow-connector/src/pair.ts +66 -0
- package/packages/flow-connector/src/serve.ts +103 -0
- package/packages/flow-connector/src/snapshot.ts +94 -0
- package/packages/flow-connector/src/tools.ts +198 -0
- package/packages/flow-connector/tsconfig.json +10 -0
- package/packages/link-protocol/package.json +17 -0
- package/packages/link-protocol/src/index.ts +245 -0
- package/packages/link-protocol/tsconfig.json +10 -0
- package/tsconfig.base.json +18 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain mirror — DB-backed. Ingests snapshots pushed by FLOW so the Brain is
|
|
3
|
+
* browsable even when the project PC is offline, and embeds each memory into
|
|
4
|
+
* sqlite-vec for recall (the "up" side of the learning loop, PRD §2.7).
|
|
5
|
+
*/
|
|
6
|
+
import { eq, desc } from "drizzle-orm";
|
|
7
|
+
import type { SnapshotPushMessage } from "@mop/link-protocol";
|
|
8
|
+
import { getDb } from "../db/client";
|
|
9
|
+
import { memoryEntry, projectMirror } from "../db/schema";
|
|
10
|
+
import { embedAndIndex } from "../memory/embed";
|
|
11
|
+
|
|
12
|
+
export type MirrorSummary = {
|
|
13
|
+
projectId: string;
|
|
14
|
+
state: unknown;
|
|
15
|
+
artifacts: SnapshotPushMessage["artifacts"];
|
|
16
|
+
memoryCount: number;
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function ingestSnapshot(snap: SnapshotPushMessage): Promise<void> {
|
|
21
|
+
const db = getDb();
|
|
22
|
+
|
|
23
|
+
db.insert(projectMirror)
|
|
24
|
+
.values({
|
|
25
|
+
projectId: snap.projectId,
|
|
26
|
+
stateJson: JSON.stringify(snap.state),
|
|
27
|
+
artifactsJson: JSON.stringify(snap.artifacts),
|
|
28
|
+
updatedAt: Date.now(),
|
|
29
|
+
})
|
|
30
|
+
.onConflictDoUpdate({
|
|
31
|
+
target: projectMirror.projectId,
|
|
32
|
+
set: {
|
|
33
|
+
stateJson: JSON.stringify(snap.state),
|
|
34
|
+
artifactsJson: JSON.stringify(snap.artifacts),
|
|
35
|
+
updatedAt: Date.now(),
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
.run();
|
|
39
|
+
|
|
40
|
+
for (const m of snap.memory) {
|
|
41
|
+
db.insert(memoryEntry)
|
|
42
|
+
.values({
|
|
43
|
+
id: m.id,
|
|
44
|
+
projectId: snap.projectId,
|
|
45
|
+
kind: String(m.kind),
|
|
46
|
+
summary: m.summary,
|
|
47
|
+
body: m.body ?? null,
|
|
48
|
+
actor: m.actor ?? null,
|
|
49
|
+
at: m.at,
|
|
50
|
+
private: m.private ?? true,
|
|
51
|
+
})
|
|
52
|
+
.onConflictDoNothing()
|
|
53
|
+
.run();
|
|
54
|
+
await embedAndIndex("episodic", m.id, `${m.summary}\n${m.body ?? ""}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function listProjectMemory(projectId: string, limit = 100): Array<typeof memoryEntry.$inferSelect> {
|
|
59
|
+
return getDb()
|
|
60
|
+
.select()
|
|
61
|
+
.from(memoryEntry)
|
|
62
|
+
.where(eq(memoryEntry.projectId, projectId))
|
|
63
|
+
.orderBy(desc(memoryEntry.at))
|
|
64
|
+
.limit(limit)
|
|
65
|
+
.all();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getMirror(projectId: string): MirrorSummary | undefined {
|
|
69
|
+
const db = getDb();
|
|
70
|
+
const [row] = db.select().from(projectMirror).where(eq(projectMirror.projectId, projectId)).all();
|
|
71
|
+
if (!row) return undefined;
|
|
72
|
+
const mem = db.select().from(memoryEntry).where(eq(memoryEntry.projectId, projectId)).all();
|
|
73
|
+
return {
|
|
74
|
+
projectId,
|
|
75
|
+
state: row.stateJson ? JSON.parse(row.stateJson) : {},
|
|
76
|
+
artifacts: row.artifactsJson ? JSON.parse(row.artifactsJson) : [],
|
|
77
|
+
memoryCount: mem.length,
|
|
78
|
+
updatedAt: row.updatedAt,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background scheduler (Fasa 3.5/5). Cron-driven jobs — currently scheduled
|
|
3
|
+
* consolidation (episodic → semantic) so the Brain grows automatically.
|
|
4
|
+
* Set MOP_AGENT_CONSOLIDATE_CRON (e.g. "0 3 * * *") to enable; off if unset.
|
|
5
|
+
*/
|
|
6
|
+
import { Cron } from "croner";
|
|
7
|
+
import { consolidate } from "./consolidate";
|
|
8
|
+
|
|
9
|
+
const g = globalThis as unknown as { __mopJobs?: Cron[] };
|
|
10
|
+
|
|
11
|
+
export function startScheduler(): string[] {
|
|
12
|
+
g.__mopJobs ??= [];
|
|
13
|
+
const started: string[] = [];
|
|
14
|
+
|
|
15
|
+
const expr = process.env.MOP_AGENT_CONSOLIDATE_CRON;
|
|
16
|
+
if (expr) {
|
|
17
|
+
const job = new Cron(expr, async () => {
|
|
18
|
+
try {
|
|
19
|
+
const r = await consolidate();
|
|
20
|
+
console.log(`[cron] consolidate → ${r.notesCreated} pattern(s) from ${r.scanned} memories`);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error("[cron] consolidate failed:", e);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
g.__mopJobs.push(job);
|
|
26
|
+
started.push(`consolidate@${expr}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return started;
|
|
30
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills registry (Fasa 5) — procedural memory layer of the Brain. Reusable
|
|
3
|
+
* how-to that recall can surface across projects (PRD §2.1).
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { getDb } from "../db/client";
|
|
7
|
+
import { skill } from "../db/schema";
|
|
8
|
+
import { embedAndIndex } from "../memory/embed";
|
|
9
|
+
|
|
10
|
+
export async function addSkill(input: {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
body: string;
|
|
14
|
+
sourceProjects?: string[];
|
|
15
|
+
}): Promise<string> {
|
|
16
|
+
const id = `skill-${randomUUID().slice(0, 8)}`;
|
|
17
|
+
getDb()
|
|
18
|
+
.insert(skill)
|
|
19
|
+
.values({
|
|
20
|
+
id,
|
|
21
|
+
name: input.name,
|
|
22
|
+
description: input.description,
|
|
23
|
+
body: input.body,
|
|
24
|
+
sourceProjects: input.sourceProjects ?? [],
|
|
25
|
+
createdAt: Date.now(),
|
|
26
|
+
})
|
|
27
|
+
.run();
|
|
28
|
+
await embedAndIndex("skill", id, `${input.name}\n${input.description}\n${input.body}`);
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function listSkills(): Array<typeof skill.$inferSelect> {
|
|
33
|
+
return getDb().select().from(skill).all();
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Channel ↔ project binding (which project a chat talks to). */
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { getDb } from "../db/client";
|
|
4
|
+
import { channelBinding } from "../db/schema";
|
|
5
|
+
import { listProjects } from "../link/store";
|
|
6
|
+
|
|
7
|
+
export function getBinding(channelKey: string): string | undefined {
|
|
8
|
+
const [row] = getDb().select().from(channelBinding).where(eq(channelBinding.channelKey, channelKey)).all();
|
|
9
|
+
return row?.projectId;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function setBinding(channelKey: string, projectId: string): void {
|
|
13
|
+
getDb()
|
|
14
|
+
.insert(channelBinding)
|
|
15
|
+
.values({ channelKey, projectId, updatedAt: Date.now() })
|
|
16
|
+
.onConflictDoUpdate({ target: channelBinding.channelKey, set: { projectId, updatedAt: Date.now() } })
|
|
17
|
+
.run();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolve the project for a chat: explicit binding, else the only project, else undefined. */
|
|
21
|
+
export function resolveProject(channelKey: string): string | undefined {
|
|
22
|
+
const bound = getBinding(channelKey);
|
|
23
|
+
if (bound) return bound;
|
|
24
|
+
const projects = listProjects();
|
|
25
|
+
return projects.length === 1 ? projects[0]!.id : undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Discord adapter (discord.js). Started only if DISCORD_BOT_TOKEN is set. */
|
|
2
|
+
import { Client, Events, GatewayIntentBits } from "discord.js";
|
|
3
|
+
import { handleIncoming } from "./handler";
|
|
4
|
+
|
|
5
|
+
export function startDiscord(token: string): Client {
|
|
6
|
+
const client = new Client({
|
|
7
|
+
intents: [
|
|
8
|
+
GatewayIntentBits.Guilds,
|
|
9
|
+
GatewayIntentBits.GuildMessages,
|
|
10
|
+
GatewayIntentBits.MessageContent,
|
|
11
|
+
GatewayIntentBits.DirectMessages,
|
|
12
|
+
],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
client.on(Events.MessageCreate, async (msg) => {
|
|
16
|
+
if (msg.author.bot) return;
|
|
17
|
+
const key = `discord:${msg.channelId}`;
|
|
18
|
+
try {
|
|
19
|
+
const reply = await handleIncoming(key, msg.content);
|
|
20
|
+
if (reply) await msg.reply(reply.slice(0, 1900)); // Discord 2000-char limit
|
|
21
|
+
} catch (e) {
|
|
22
|
+
await msg.reply(`error: ${e instanceof Error ? e.message : String(e)}`);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
void client.login(token);
|
|
27
|
+
return client;
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-neutral channel message handler. Telegram/Discord adapters call this
|
|
3
|
+
* with a namespaced channelKey ("telegram:123", "discord:456") and the text;
|
|
4
|
+
* it handles commands and otherwise answers grounded on the bound project.
|
|
5
|
+
*/
|
|
6
|
+
import { listProjects } from "../link/store";
|
|
7
|
+
import { groundedAnswerText } from "../brain/answer";
|
|
8
|
+
import { resolveProject, setBinding } from "./binding";
|
|
9
|
+
|
|
10
|
+
const HELP = [
|
|
11
|
+
"🧠 MOP-AGENT brain.",
|
|
12
|
+
"Commands:",
|
|
13
|
+
"• /projects — list linked projects",
|
|
14
|
+
"• /use <id> — bind this chat to a project",
|
|
15
|
+
"Then just ask a question.",
|
|
16
|
+
].join("\n");
|
|
17
|
+
|
|
18
|
+
export async function handleIncoming(channelKey: string, text: string): Promise<string> {
|
|
19
|
+
const t = (text ?? "").trim();
|
|
20
|
+
if (!t) return "";
|
|
21
|
+
|
|
22
|
+
if (t === "/help" || t === "/start") return HELP;
|
|
23
|
+
|
|
24
|
+
if (t === "/projects") {
|
|
25
|
+
const ps = listProjects();
|
|
26
|
+
return ps.length
|
|
27
|
+
? "Projects:\n" + ps.map((p) => `• ${p.id} (${p.status})`).join("\n")
|
|
28
|
+
: "No projects linked yet.";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (t.startsWith("/use ")) {
|
|
32
|
+
const id = t.slice(5).trim();
|
|
33
|
+
if (!listProjects().some((p) => p.id === id)) return `Unknown project: "${id}". Try /projects.`;
|
|
34
|
+
setBinding(channelKey, id);
|
|
35
|
+
return `✅ This chat is now bound to "${id}". Ask away.`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const projectId = resolveProject(channelKey);
|
|
39
|
+
if (!projectId) return "Which project? Use /projects then /use <id>.";
|
|
40
|
+
|
|
41
|
+
const { text: answer, provider } = await groundedAnswerText(projectId, t);
|
|
42
|
+
const note = provider === "echo" ? "\n\n(echo — set ANTHROPIC_API_KEY/OPENROUTER_API_KEY for real answers)" : "";
|
|
43
|
+
return `${answer}${note}`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start messaging channels for which a bot token is configured. SDKs are loaded
|
|
3
|
+
* lazily (dynamic import) so they cost nothing when unused.
|
|
4
|
+
*/
|
|
5
|
+
export async function startChannels(): Promise<string[]> {
|
|
6
|
+
const started: string[] = [];
|
|
7
|
+
if (process.env.TELEGRAM_BOT_TOKEN) {
|
|
8
|
+
const { startTelegram } = await import("./telegram");
|
|
9
|
+
startTelegram(process.env.TELEGRAM_BOT_TOKEN);
|
|
10
|
+
started.push("telegram");
|
|
11
|
+
}
|
|
12
|
+
if (process.env.DISCORD_BOT_TOKEN) {
|
|
13
|
+
const { startDiscord } = await import("./discord");
|
|
14
|
+
startDiscord(process.env.DISCORD_BOT_TOKEN);
|
|
15
|
+
started.push("discord");
|
|
16
|
+
}
|
|
17
|
+
return started;
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Telegram adapter (grammy). Long-polling; started only if TELEGRAM_BOT_TOKEN is set. */
|
|
2
|
+
import { Bot } from "grammy";
|
|
3
|
+
import { handleIncoming } from "./handler";
|
|
4
|
+
|
|
5
|
+
export function startTelegram(token: string): Bot {
|
|
6
|
+
const bot = new Bot(token);
|
|
7
|
+
bot.on("message:text", async (ctx) => {
|
|
8
|
+
const key = `telegram:${ctx.chat.id}`;
|
|
9
|
+
try {
|
|
10
|
+
const reply = await handleIncoming(key, ctx.message.text);
|
|
11
|
+
if (reply) await ctx.reply(reply);
|
|
12
|
+
} catch (e) {
|
|
13
|
+
await ctx.reply(`error: ${e instanceof Error ? e.message : String(e)}`);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
void bot.start();
|
|
17
|
+
return bot;
|
|
18
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret-at-rest encryption (AES-256-GCM). Key derived from MOP_AGENT_SECRET
|
|
3
|
+
* (64 hex chars = 32 bytes). Used to store provider API keys encrypted.
|
|
4
|
+
* Format: base64(iv).base64(tag).base64(ciphertext)
|
|
5
|
+
*/
|
|
6
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
function key(): Buffer {
|
|
9
|
+
const secret = process.env.MOP_AGENT_SECRET;
|
|
10
|
+
if (secret && /^[0-9a-fA-F]{64}$/.test(secret)) return Buffer.from(secret, "hex");
|
|
11
|
+
// Dev fallback: derive a 32-byte key from whatever secret is present (insecure).
|
|
12
|
+
return createHash("sha256").update(secret ?? "mop-agent-dev-insecure-key").digest();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function encryptSecret(plain: string): string {
|
|
16
|
+
const iv = randomBytes(12);
|
|
17
|
+
const cipher = createCipheriv("aes-256-gcm", key(), iv);
|
|
18
|
+
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
19
|
+
const tag = cipher.getAuthTag();
|
|
20
|
+
return [iv, tag, enc].map((b) => b.toString("base64")).join(".");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function decryptSecret(encoded: string): string {
|
|
24
|
+
const [ivB, tagB, dataB] = encoded.split(".");
|
|
25
|
+
if (!ivB || !tagB || !dataB) throw new Error("bad ciphertext");
|
|
26
|
+
const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(ivB, "base64"));
|
|
27
|
+
decipher.setAuthTag(Buffer.from(tagB, "base64"));
|
|
28
|
+
return Buffer.concat([decipher.update(Buffer.from(dataB, "base64")), decipher.final()]).toString("utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** "sk-…last4" style hint, never the full key. */
|
|
32
|
+
export function keyHint(plain: string): string {
|
|
33
|
+
if (plain.length <= 8) return "••••";
|
|
34
|
+
return `${plain.slice(0, 3)}…${plain.slice(-4)}`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SQLite connection: one better-sqlite3 instance used by BOTH Drizzle
|
|
3
|
+
* (app tables) and Better Auth (its Kysely adapter). better-sqlite3 is synchronous,
|
|
4
|
+
* so a single shared connection is correct and simplest.
|
|
5
|
+
*
|
|
6
|
+
* sqlite-vec is loaded onto the connection so vector queries work everywhere.
|
|
7
|
+
*/
|
|
8
|
+
import Database from "better-sqlite3";
|
|
9
|
+
import * as sqliteVec from "sqlite-vec";
|
|
10
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
11
|
+
import { mkdirSync } from "node:fs";
|
|
12
|
+
import { dataDir, dbPath } from "./paths";
|
|
13
|
+
import * as schema from "./schema";
|
|
14
|
+
|
|
15
|
+
let _sqlite: Database.Database | null = null;
|
|
16
|
+
|
|
17
|
+
export function getSqlite(): Database.Database {
|
|
18
|
+
if (_sqlite) return _sqlite;
|
|
19
|
+
mkdirSync(dataDir(), { recursive: true });
|
|
20
|
+
const db = new Database(dbPath());
|
|
21
|
+
db.pragma("journal_mode = WAL");
|
|
22
|
+
db.pragma("foreign_keys = ON");
|
|
23
|
+
sqliteVec.load(db);
|
|
24
|
+
_sqlite = db;
|
|
25
|
+
return db;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let _db: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
|
29
|
+
|
|
30
|
+
export function getDb() {
|
|
31
|
+
if (_db) return _db;
|
|
32
|
+
_db = drizzle(getSqlite(), { schema });
|
|
33
|
+
return _db;
|
|
34
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrations: app tables (raw SQL, idempotent) + sqlite-vec virtual table +
|
|
3
|
+
* Better Auth tables (via better-auth/db/migration). Safe to run repeatedly.
|
|
4
|
+
*/
|
|
5
|
+
import { getSqlite } from "./client";
|
|
6
|
+
import { EMBED_DIM } from "../memory/embed";
|
|
7
|
+
|
|
8
|
+
const APP_TABLES_SQL = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS project (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
name TEXT NOT NULL,
|
|
12
|
+
mop_flow_version TEXT,
|
|
13
|
+
link_token_hash TEXT NOT NULL,
|
|
14
|
+
capabilities TEXT NOT NULL,
|
|
15
|
+
status TEXT NOT NULL DEFAULT 'offline',
|
|
16
|
+
last_seen_at INTEGER,
|
|
17
|
+
created_at INTEGER NOT NULL
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS pairing_code (
|
|
21
|
+
code TEXT PRIMARY KEY,
|
|
22
|
+
expires_at INTEGER NOT NULL,
|
|
23
|
+
used_at INTEGER,
|
|
24
|
+
created_at INTEGER NOT NULL
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS project_mirror (
|
|
28
|
+
project_id TEXT PRIMARY KEY,
|
|
29
|
+
state_json TEXT,
|
|
30
|
+
artifacts_json TEXT,
|
|
31
|
+
updated_at INTEGER NOT NULL
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS memory_entry (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
project_id TEXT NOT NULL,
|
|
37
|
+
kind TEXT NOT NULL,
|
|
38
|
+
summary TEXT NOT NULL,
|
|
39
|
+
body TEXT,
|
|
40
|
+
actor TEXT,
|
|
41
|
+
at INTEGER NOT NULL,
|
|
42
|
+
private INTEGER NOT NULL DEFAULT 1
|
|
43
|
+
);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_memory_project ON memory_entry(project_id);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS semantic_note (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
title TEXT NOT NULL,
|
|
49
|
+
body TEXT NOT NULL,
|
|
50
|
+
source_projects TEXT,
|
|
51
|
+
confidence INTEGER NOT NULL DEFAULT 50,
|
|
52
|
+
created_at INTEGER NOT NULL
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS channel_binding (
|
|
56
|
+
channel_key TEXT PRIMARY KEY,
|
|
57
|
+
project_id TEXT NOT NULL,
|
|
58
|
+
updated_at INTEGER NOT NULL
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS app_role (
|
|
62
|
+
user_id TEXT PRIMARY KEY,
|
|
63
|
+
role TEXT NOT NULL
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS invite (
|
|
67
|
+
email TEXT PRIMARY KEY,
|
|
68
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
69
|
+
expires_at INTEGER NOT NULL,
|
|
70
|
+
used_at INTEGER,
|
|
71
|
+
invited_by TEXT,
|
|
72
|
+
created_at INTEGER NOT NULL
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE TABLE IF NOT EXISTS provider_config (
|
|
76
|
+
owner_id TEXT PRIMARY KEY,
|
|
77
|
+
provider TEXT NOT NULL,
|
|
78
|
+
api_key_enc TEXT NOT NULL,
|
|
79
|
+
model TEXT,
|
|
80
|
+
updated_at INTEGER NOT NULL
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS skill (
|
|
84
|
+
id TEXT PRIMARY KEY,
|
|
85
|
+
name TEXT NOT NULL,
|
|
86
|
+
description TEXT NOT NULL,
|
|
87
|
+
body TEXT NOT NULL,
|
|
88
|
+
source_projects TEXT,
|
|
89
|
+
created_at INTEGER NOT NULL
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
CREATE TABLE IF NOT EXISTS vec_map (
|
|
93
|
+
rowid INTEGER PRIMARY KEY,
|
|
94
|
+
ref_type TEXT NOT NULL,
|
|
95
|
+
ref_id TEXT NOT NULL
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_vecmap_ref ON vec_map(ref_type, ref_id);
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
export async function runAllMigrations(): Promise<void> {
|
|
101
|
+
const sqlite = getSqlite();
|
|
102
|
+
|
|
103
|
+
// App tables
|
|
104
|
+
sqlite.exec(APP_TABLES_SQL);
|
|
105
|
+
|
|
106
|
+
// sqlite-vec virtual table (raw — Drizzle can't model vec0)
|
|
107
|
+
sqlite.exec(
|
|
108
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS vec_memory USING vec0(embedding float[${EMBED_DIM}]);`,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Better Auth tables (user, session, account, verification)
|
|
112
|
+
const { getMigrations } = await import("better-auth/db/migration");
|
|
113
|
+
const { auth } = await import("../auth.js");
|
|
114
|
+
const { runMigrations } = await getMigrations(auth.options);
|
|
115
|
+
await runMigrations();
|
|
116
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform data directory resolution (Windows + Linux + macOS).
|
|
3
|
+
* Override with MOP_AGENT_DATA_DIR. Defaults to <cwd>/data in dev for easy inspection.
|
|
4
|
+
*/
|
|
5
|
+
import { homedir, platform } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export function dataDir(): string {
|
|
9
|
+
if (process.env.MOP_AGENT_DATA_DIR) return process.env.MOP_AGENT_DATA_DIR;
|
|
10
|
+
|
|
11
|
+
// Production-friendly OS locations (used when not in a project checkout).
|
|
12
|
+
if (process.env.MOP_AGENT_USE_OS_DIR === "1") {
|
|
13
|
+
if (platform() === "win32") {
|
|
14
|
+
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "mop-agent");
|
|
15
|
+
}
|
|
16
|
+
return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "mop-agent");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Dev default: ./data (gitignored)
|
|
20
|
+
return join(process.cwd(), "data");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function dbPath(): string {
|
|
24
|
+
return join(dataDir(), "mop-agent.db");
|
|
25
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle schema (app tables). Better Auth owns its own tables (user, session,
|
|
3
|
+
* account, verification) — created by Better Auth migrations on the same DB file.
|
|
4
|
+
*
|
|
5
|
+
* The sqlite-vec virtual table `vec_memory` is created via raw SQL in migrate.ts
|
|
6
|
+
* (Drizzle can't model a vec0 virtual table).
|
|
7
|
+
*
|
|
8
|
+
* Timestamps are epoch millis (number) to match @mop/link-protocol.
|
|
9
|
+
*/
|
|
10
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
11
|
+
import type { Capabilities } from "@mop/link-protocol";
|
|
12
|
+
|
|
13
|
+
export const project = sqliteTable("project", {
|
|
14
|
+
id: text("id").primaryKey(),
|
|
15
|
+
name: text("name").notNull(),
|
|
16
|
+
mopFlowVersion: text("mop_flow_version"),
|
|
17
|
+
linkTokenHash: text("link_token_hash").notNull(),
|
|
18
|
+
capabilities: text("capabilities", { mode: "json" }).$type<Capabilities>().notNull(),
|
|
19
|
+
status: text("status").notNull().default("offline"),
|
|
20
|
+
lastSeenAt: integer("last_seen_at"),
|
|
21
|
+
createdAt: integer("created_at").notNull(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const pairingCode = sqliteTable("pairing_code", {
|
|
25
|
+
code: text("code").primaryKey(),
|
|
26
|
+
expiresAt: integer("expires_at").notNull(),
|
|
27
|
+
usedAt: integer("used_at"),
|
|
28
|
+
createdAt: integer("created_at").notNull(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const projectMirror = sqliteTable("project_mirror", {
|
|
32
|
+
projectId: text("project_id").primaryKey(),
|
|
33
|
+
stateJson: text("state_json"),
|
|
34
|
+
artifactsJson: text("artifacts_json"),
|
|
35
|
+
updatedAt: integer("updated_at").notNull(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const memoryEntry = sqliteTable("memory_entry", {
|
|
39
|
+
id: text("id").primaryKey(),
|
|
40
|
+
projectId: text("project_id").notNull(),
|
|
41
|
+
kind: text("kind").notNull(),
|
|
42
|
+
summary: text("summary").notNull(),
|
|
43
|
+
body: text("body"),
|
|
44
|
+
actor: text("actor"),
|
|
45
|
+
at: integer("at").notNull(),
|
|
46
|
+
private: integer("private", { mode: "boolean" }).notNull().default(true),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const semanticNote = sqliteTable("semantic_note", {
|
|
50
|
+
id: text("id").primaryKey(),
|
|
51
|
+
title: text("title").notNull(),
|
|
52
|
+
body: text("body").notNull(),
|
|
53
|
+
sourceProjects: text("source_projects", { mode: "json" }).$type<string[]>(),
|
|
54
|
+
confidence: integer("confidence").notNull().default(50), // 0-100
|
|
55
|
+
createdAt: integer("created_at").notNull(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Binds a messaging channel/chat to a project (Fasa 4.5). channelKey = "platform:chatId". */
|
|
59
|
+
export const channelBinding = sqliteTable("channel_binding", {
|
|
60
|
+
channelKey: text("channel_key").primaryKey(),
|
|
61
|
+
projectId: text("project_id").notNull(),
|
|
62
|
+
updatedAt: integer("updated_at").notNull(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** App role per Better Auth user (Fasa 7 team). owner | member. */
|
|
66
|
+
export const appRole = sqliteTable("app_role", {
|
|
67
|
+
userId: text("user_id").primaryKey(),
|
|
68
|
+
role: text("role").notNull(), // "owner" | "member"
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** Email-scoped invite — owner invites a specific email to join. */
|
|
72
|
+
export const invite = sqliteTable("invite", {
|
|
73
|
+
email: text("email").primaryKey(),
|
|
74
|
+
role: text("role").notNull().default("member"),
|
|
75
|
+
expiresAt: integer("expires_at").notNull(),
|
|
76
|
+
usedAt: integer("used_at"),
|
|
77
|
+
invitedBy: text("invited_by"),
|
|
78
|
+
createdAt: integer("created_at").notNull(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Per-owner AI provider config; apiKeyEnc is AES-256-GCM encrypted at rest. */
|
|
82
|
+
export const providerConfig = sqliteTable("provider_config", {
|
|
83
|
+
ownerId: text("owner_id").primaryKey(),
|
|
84
|
+
provider: text("provider").notNull(), // "anthropic" | "openrouter"
|
|
85
|
+
apiKeyEnc: text("api_key_enc").notNull(),
|
|
86
|
+
model: text("model"),
|
|
87
|
+
updatedAt: integer("updated_at").notNull(),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** Procedural memory — reusable how-to / skills (Fasa 5). Shared across projects. */
|
|
91
|
+
export const skill = sqliteTable("skill", {
|
|
92
|
+
id: text("id").primaryKey(),
|
|
93
|
+
name: text("name").notNull(),
|
|
94
|
+
description: text("description").notNull(),
|
|
95
|
+
body: text("body").notNull(),
|
|
96
|
+
sourceProjects: text("source_projects", { mode: "json" }).$type<string[]>(),
|
|
97
|
+
createdAt: integer("created_at").notNull(),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** Maps a sqlite-vec rowid to the memory/semantic row it embeds. */
|
|
101
|
+
export const vecMap = sqliteTable("vec_map", {
|
|
102
|
+
rowid: integer("rowid").primaryKey(),
|
|
103
|
+
refType: text("ref_type").notNull(), // "episodic" | "semantic"
|
|
104
|
+
refId: text("ref_id").notNull(),
|
|
105
|
+
});
|