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,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/** Team — your role, members, and (owner) invite management. */
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
type Me = { user: { email: string; name: string }; role: string };
|
|
6
|
+
type Member = { id: string; email: string; name: string; role: string };
|
|
7
|
+
type Invite = { email: string; role: string; expiresAt: number; usedAt: number | null };
|
|
8
|
+
|
|
9
|
+
export default function TeamPage() {
|
|
10
|
+
const [me, setMe] = useState<Me | null>(null);
|
|
11
|
+
const [members, setMembers] = useState<Member[]>([]);
|
|
12
|
+
const [invites, setInvites] = useState<Invite[]>([]);
|
|
13
|
+
const [email, setEmail] = useState("");
|
|
14
|
+
const [role, setRole] = useState<"member" | "owner">("member");
|
|
15
|
+
const [msg, setMsg] = useState("");
|
|
16
|
+
|
|
17
|
+
const isOwner = me?.role === "owner";
|
|
18
|
+
|
|
19
|
+
function load() {
|
|
20
|
+
fetch("/api/me").then((r) => r.json()).then(setMe).catch(() => {});
|
|
21
|
+
fetch("/api/members").then((r) => (r.ok ? r.json() : { members: [] })).then((d) => setMembers(d.members ?? [])).catch(() => {});
|
|
22
|
+
fetch("/api/invites").then((r) => (r.ok ? r.json() : { invites: [] })).then((d) => setInvites(d.invites ?? [])).catch(() => {});
|
|
23
|
+
}
|
|
24
|
+
useEffect(load, []);
|
|
25
|
+
|
|
26
|
+
async function invite(e: React.FormEvent) {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
setMsg("…");
|
|
29
|
+
const r = await fetch("/api/invites", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, role }) });
|
|
30
|
+
setMsg(r.ok ? `✅ invited ${email}` : `error ${r.status}`);
|
|
31
|
+
setEmail("");
|
|
32
|
+
load();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function revoke(em: string) {
|
|
36
|
+
await fetch(`/api/invites?email=${encodeURIComponent(em)}`, { method: "DELETE" });
|
|
37
|
+
load();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<main style={{ maxWidth: 640, margin: "0 auto", padding: "40px 24px" }}>
|
|
42
|
+
<a href="/brain" style={{ color: "#7aa2ff" }}>← Brain</a>
|
|
43
|
+
<h1 style={{ fontSize: 24 }}>👥 Team</h1>
|
|
44
|
+
<p style={{ opacity: 0.7 }}>You are <strong>{me?.user.email}</strong> · role <strong>{me?.role}</strong></p>
|
|
45
|
+
|
|
46
|
+
<h2 style={{ fontSize: 16, marginTop: 20 }}>Members ({members.length})</h2>
|
|
47
|
+
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
48
|
+
{members.map((m) => (
|
|
49
|
+
<li key={m.id} style={row}>{m.role === "owner" ? "👑" : "👤"} {m.email} <span style={{ opacity: 0.5 }}>· {m.role}</span></li>
|
|
50
|
+
))}
|
|
51
|
+
{!isOwner && members.length === 0 && <p style={{ opacity: 0.5 }}>Owner-only view.</p>}
|
|
52
|
+
</ul>
|
|
53
|
+
|
|
54
|
+
{isOwner && (
|
|
55
|
+
<>
|
|
56
|
+
<h2 style={{ fontSize: 16, marginTop: 20 }}>Invite a member</h2>
|
|
57
|
+
<form onSubmit={invite} style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
58
|
+
<input placeholder="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inp} />
|
|
59
|
+
<select value={role} onChange={(e) => setRole(e.target.value as "member" | "owner")} style={inp}>
|
|
60
|
+
<option value="member">member</option>
|
|
61
|
+
<option value="owner">owner</option>
|
|
62
|
+
</select>
|
|
63
|
+
<button type="submit" style={btn}>Invite</button>
|
|
64
|
+
</form>
|
|
65
|
+
{msg && <p style={{ opacity: 0.8 }}>{msg}</p>}
|
|
66
|
+
|
|
67
|
+
<h2 style={{ fontSize: 16, marginTop: 20 }}>Pending invites</h2>
|
|
68
|
+
<ul style={{ listStyle: "none", padding: 0 }}>
|
|
69
|
+
{invites.filter((i) => !i.usedAt).map((i) => (
|
|
70
|
+
<li key={i.email} style={row}>
|
|
71
|
+
✉️ {i.email} <span style={{ opacity: 0.5 }}>· {i.role}</span>
|
|
72
|
+
<button onClick={() => revoke(i.email)} style={{ float: "right", ...btn, background: "#7f1a1a", borderColor: "#7f1a1a", padding: "2px 10px" }}>revoke</button>
|
|
73
|
+
</li>
|
|
74
|
+
))}
|
|
75
|
+
{invites.filter((i) => !i.usedAt).length === 0 && <p style={{ opacity: 0.5 }}>None.</p>}
|
|
76
|
+
</ul>
|
|
77
|
+
<p style={{ opacity: 0.55, fontSize: 13 }}>Invited people sign up at <code>/setup</code> with that exact email.</p>
|
|
78
|
+
</>
|
|
79
|
+
)}
|
|
80
|
+
</main>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const row: React.CSSProperties = { border: "1px solid #1f2a3a", borderRadius: 8, padding: "8px 12px", marginBottom: 6 };
|
|
85
|
+
const inp: React.CSSProperties = { padding: "8px 10px", borderRadius: 8, border: "1px solid #1f2a3a", background: "#111824", color: "#e6edf3" };
|
|
86
|
+
const btn: React.CSSProperties = { padding: "8px 14px", borderRadius: 8, border: "1px solid #2b5cff", background: "#2b5cff", color: "white", cursor: "pointer" };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Thin `mop-agent` CLI (Fasa 3.5) — local ops against the same SQLite Brain.
|
|
4
|
+
* Loads apps/web/.env so it targets the same data dir as the server.
|
|
5
|
+
*
|
|
6
|
+
* mop-agent migrate
|
|
7
|
+
* mop-agent status
|
|
8
|
+
* mop-agent projects
|
|
9
|
+
* mop-agent consolidate
|
|
10
|
+
* mop-agent skills
|
|
11
|
+
* mop-agent skill-add "<name>" "<description>" "<body>"
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
|
|
19
|
+
// Minimal .env loader (existing env wins) so the CLI targets the same Brain as the server.
|
|
20
|
+
const envPath = join(webRoot, ".env");
|
|
21
|
+
if (existsSync(envPath)) {
|
|
22
|
+
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
|
23
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
|
|
24
|
+
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
switch (cmd) {
|
|
32
|
+
case "migrate": {
|
|
33
|
+
const { runAllMigrations } = await import("../lib/db/migrate.js");
|
|
34
|
+
await runAllMigrations();
|
|
35
|
+
console.log("✅ migrated");
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case "status": {
|
|
39
|
+
const { ownerExists } = await import("../lib/auth.js");
|
|
40
|
+
const { listProjects } = await import("../lib/link/store.js");
|
|
41
|
+
const { listSemanticNotes } = await import("../lib/brain/consolidate.js");
|
|
42
|
+
const { listSkills } = await import("../lib/brain/skills.js");
|
|
43
|
+
const projects = listProjects();
|
|
44
|
+
console.log(`owner: ${ownerExists() ? "yes" : "no (run /setup)"}`);
|
|
45
|
+
console.log(`projects: ${projects.length} (${projects.filter((p) => p.status === "online").length} online)`);
|
|
46
|
+
console.log(`main-brain patterns: ${listSemanticNotes().length}`);
|
|
47
|
+
console.log(`skills: ${listSkills().length}`);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case "projects": {
|
|
51
|
+
const { listProjects } = await import("../lib/link/store.js");
|
|
52
|
+
for (const p of listProjects()) console.log(`${p.status === "online" ? "🟢" : "⚪"} ${p.id} (${p.name})`);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case "consolidate": {
|
|
56
|
+
const { consolidate } = await import("../lib/brain/consolidate.js");
|
|
57
|
+
const r = await consolidate();
|
|
58
|
+
console.log(`scanned ${r.scanned} → ${r.notesCreated} pattern(s)`);
|
|
59
|
+
for (const n of r.notes) console.log(` • ${n.title} (${n.confidence}%)`);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case "skills": {
|
|
63
|
+
const { listSkills } = await import("../lib/brain/skills.js");
|
|
64
|
+
for (const s of listSkills()) console.log(`🛠 ${s.name} — ${s.description}`);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "skill-add": {
|
|
68
|
+
const [name, description = "", body = ""] = rest;
|
|
69
|
+
if (!name) return fail("usage: mop-agent skill-add \"<name>\" \"<description>\" \"<body>\"");
|
|
70
|
+
const { addSkill } = await import("../lib/brain/skills.js");
|
|
71
|
+
const id = await addSkill({ name, description, body: body || description || name });
|
|
72
|
+
console.log(`added ${id}`);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
console.log("commands: migrate | status | projects | consolidate | skills | skill-add");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fail(msg) {
|
|
81
|
+
console.error(msg);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main().catch((e) => fail(e instanceof Error ? e.message : String(e)));
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Better Auth — owner account + sessions + team (Fasa 7).
|
|
3
|
+
*
|
|
4
|
+
* Uses the SAME better-sqlite3 connection as Drizzle (its Kysely adapter detects
|
|
5
|
+
* the instance). First user to register becomes the owner; further signups are
|
|
6
|
+
* invite-gated (email-scoped). Roles live in app_role.
|
|
7
|
+
*/
|
|
8
|
+
import { betterAuth } from "better-auth";
|
|
9
|
+
import { APIError } from "better-auth/api";
|
|
10
|
+
import { getSqlite } from "./db/client";
|
|
11
|
+
|
|
12
|
+
function userCount(): number {
|
|
13
|
+
try {
|
|
14
|
+
const row = getSqlite().prepare("SELECT count(*) AS c FROM user").get() as { c: number };
|
|
15
|
+
return row.c;
|
|
16
|
+
} catch {
|
|
17
|
+
return 0; // table not migrated yet
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validInviteFor(email: string): { role: string } | undefined {
|
|
22
|
+
try {
|
|
23
|
+
return getSqlite()
|
|
24
|
+
.prepare("SELECT role FROM invite WHERE email = ? AND used_at IS NULL AND expires_at > ?")
|
|
25
|
+
.get(email, Date.now()) as { role: string } | undefined;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getRole(userId: string): "owner" | "member" | undefined {
|
|
32
|
+
try {
|
|
33
|
+
const row = getSqlite().prepare("SELECT role FROM app_role WHERE user_id = ?").get(userId) as { role: string } | undefined;
|
|
34
|
+
return row?.role as "owner" | "member" | undefined;
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const auth = betterAuth({
|
|
41
|
+
database: getSqlite(),
|
|
42
|
+
secret: process.env.BETTER_AUTH_SECRET ?? "dev-insecure-secret-change-me",
|
|
43
|
+
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
|
44
|
+
emailAndPassword: {
|
|
45
|
+
enabled: true,
|
|
46
|
+
requireEmailVerification: false,
|
|
47
|
+
},
|
|
48
|
+
session: {
|
|
49
|
+
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
|
50
|
+
updateAge: 60 * 60 * 24, // refresh daily
|
|
51
|
+
},
|
|
52
|
+
databaseHooks: {
|
|
53
|
+
user: {
|
|
54
|
+
create: {
|
|
55
|
+
before: async (user) => {
|
|
56
|
+
// First user = owner. Otherwise an email-scoped invite is required.
|
|
57
|
+
if (userCount() === 0) return { data: user };
|
|
58
|
+
if (!validInviteFor(user.email)) {
|
|
59
|
+
throw new APIError("FORBIDDEN", {
|
|
60
|
+
message: "Signup requires an invite for this email.",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return { data: user };
|
|
64
|
+
},
|
|
65
|
+
after: async (user) => {
|
|
66
|
+
const sqlite = getSqlite();
|
|
67
|
+
let role = "member";
|
|
68
|
+
if (userCount() === 1) {
|
|
69
|
+
role = "owner"; // the bootstrap user
|
|
70
|
+
} else {
|
|
71
|
+
const inv = validInviteFor(user.email);
|
|
72
|
+
if (inv) {
|
|
73
|
+
role = inv.role;
|
|
74
|
+
sqlite.prepare("UPDATE invite SET used_at = ? WHERE email = ?").run(Date.now(), user.email);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
sqlite.prepare("INSERT OR REPLACE INTO app_role(user_id, role) VALUES(?, ?)").run(user.id, role);
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export function ownerExists(): boolean {
|
|
85
|
+
return userCount() > 0;
|
|
86
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Role-based authorization helper (Fasa 7). */
|
|
2
|
+
import { auth, getRole } from "./auth";
|
|
3
|
+
|
|
4
|
+
export type Role = "owner" | "member";
|
|
5
|
+
|
|
6
|
+
export type AuthzResult =
|
|
7
|
+
| { ok: true; userId: string; role: Role }
|
|
8
|
+
| { ok: false; response: Response };
|
|
9
|
+
|
|
10
|
+
export async function requireRole(req: Request, roles: Role[]): Promise<AuthzResult> {
|
|
11
|
+
const session = await auth.api.getSession({ headers: req.headers });
|
|
12
|
+
if (!session) return { ok: false, response: Response.json({ error: "unauthorized" }, { status: 401 }) };
|
|
13
|
+
const role = (getRole(session.user.id) ?? "member") as Role;
|
|
14
|
+
if (!roles.includes(role)) {
|
|
15
|
+
return { ok: false, response: Response.json({ error: "forbidden", needed: roles, have: role }, { status: 403 }) };
|
|
16
|
+
}
|
|
17
|
+
return { ok: true, userId: session.user.id, role };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Any authenticated member. */
|
|
21
|
+
export async function requireAuth(req: Request): Promise<AuthzResult> {
|
|
22
|
+
return requireRole(req, ["owner", "member"]);
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-streaming grounded answer (recall + provider) — used by channels and any
|
|
3
|
+
* non-SSE caller. /api/chat keeps its own streaming variant.
|
|
4
|
+
*/
|
|
5
|
+
import { recall } from "./broker";
|
|
6
|
+
import { resolveProvider } from "../providers";
|
|
7
|
+
|
|
8
|
+
export async function groundedAnswerText(
|
|
9
|
+
projectId: string,
|
|
10
|
+
message: string,
|
|
11
|
+
opts?: { allowCrossProject?: boolean },
|
|
12
|
+
): Promise<{ text: string; provider: string; contextCount: number }> {
|
|
13
|
+
const pack = await recall({ query: message, projectId, allowCrossProject: opts?.allowCrossProject });
|
|
14
|
+
const provider = resolveProvider();
|
|
15
|
+
const system = [
|
|
16
|
+
"You are the MOP-AGENT brain. Answer using the project context below when relevant.",
|
|
17
|
+
"If the context is empty, say so plainly.",
|
|
18
|
+
"",
|
|
19
|
+
pack.toPromptString(),
|
|
20
|
+
].join("\n");
|
|
21
|
+
|
|
22
|
+
let text = "";
|
|
23
|
+
for await (const delta of provider.chat({ system, messages: [{ role: "user", content: message }] })) {
|
|
24
|
+
text += delta;
|
|
25
|
+
}
|
|
26
|
+
return { text, provider: provider.id, contextCount: pack.episodic.length + pack.semantic.length };
|
|
27
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval queue for AGENT → FLOW write actions (Fasa 4).
|
|
3
|
+
*
|
|
4
|
+
* Security model: writes (append_memory / write_artifact / workflow_next / shell …)
|
|
5
|
+
* never auto-execute. They land here as pending, the owner approves, then we send
|
|
6
|
+
* them down the live link. FLOW re-checks capability + session regardless (the
|
|
7
|
+
* approval flag is never trusted FLOW-side — defense in depth).
|
|
8
|
+
*
|
|
9
|
+
* In-memory + globalThis (pending actions are transient; survive Next bundle split).
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { isReadTool, type McpToolName } from "@mop/link-protocol";
|
|
13
|
+
import { callFlow, isOnline } from "../ws/gateway";
|
|
14
|
+
|
|
15
|
+
export type ActionStatus = "pending" | "denied" | "executed" | "failed";
|
|
16
|
+
|
|
17
|
+
export type Action = {
|
|
18
|
+
id: string;
|
|
19
|
+
projectId: string;
|
|
20
|
+
tool: McpToolName;
|
|
21
|
+
args: Record<string, unknown>;
|
|
22
|
+
summary: string;
|
|
23
|
+
status: ActionStatus;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
result?: unknown;
|
|
26
|
+
error?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const g = globalThis as unknown as { __mopActions?: Map<string, Action> };
|
|
30
|
+
const actions = (g.__mopActions ??= new Map<string, Action>());
|
|
31
|
+
|
|
32
|
+
export function requestAction(input: {
|
|
33
|
+
projectId: string;
|
|
34
|
+
tool: McpToolName;
|
|
35
|
+
args: Record<string, unknown>;
|
|
36
|
+
summary?: string;
|
|
37
|
+
}): Action {
|
|
38
|
+
const action: Action = {
|
|
39
|
+
id: `act-${randomUUID().slice(0, 8)}`,
|
|
40
|
+
projectId: input.projectId,
|
|
41
|
+
tool: input.tool,
|
|
42
|
+
args: input.args,
|
|
43
|
+
summary: input.summary ?? `${input.tool} on ${input.projectId}`,
|
|
44
|
+
status: "pending",
|
|
45
|
+
createdAt: Date.now(),
|
|
46
|
+
};
|
|
47
|
+
actions.set(action.id, action);
|
|
48
|
+
return action;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function listActions(): Action[] {
|
|
52
|
+
return [...actions.values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function denyAction(id: string): Action | undefined {
|
|
56
|
+
const a = actions.get(id);
|
|
57
|
+
if (a && a.status === "pending") a.status = "denied";
|
|
58
|
+
return a;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Approve → execute over the live link. FLOW enforces capability + session. */
|
|
62
|
+
export async function approveAction(id: string): Promise<Action | undefined> {
|
|
63
|
+
const a = actions.get(id);
|
|
64
|
+
if (!a || a.status !== "pending") return a;
|
|
65
|
+
|
|
66
|
+
// Read tools never needed approval, but allow execution anyway.
|
|
67
|
+
if (!isReadTool(a.tool) && !isOnline(a.projectId)) {
|
|
68
|
+
a.status = "failed";
|
|
69
|
+
a.error = "project_offline";
|
|
70
|
+
return a;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
a.result = await callFlow(a.projectId, a.tool, a.args);
|
|
75
|
+
a.status = "executed";
|
|
76
|
+
} catch (e) {
|
|
77
|
+
a.status = "failed";
|
|
78
|
+
a.error = e instanceof Error ? e.message : String(e);
|
|
79
|
+
}
|
|
80
|
+
return a;
|
|
81
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory broker — federated recall with the judgment layer (PRD §2.3–2.4).
|
|
3
|
+
*
|
|
4
|
+
* Episodic memory is private to its project by default. Main Brain semantic notes
|
|
5
|
+
* are always shareable. Cross-project episodic recall requires allowCrossProject
|
|
6
|
+
* (TODO: gate on an explicit project link once links are modelled).
|
|
7
|
+
*/
|
|
8
|
+
import { inArray } from "drizzle-orm";
|
|
9
|
+
import { getDb } from "../db/client";
|
|
10
|
+
import { memoryEntry, semanticNote, skill } from "../db/schema";
|
|
11
|
+
import { semanticSearch } from "../memory/embed";
|
|
12
|
+
|
|
13
|
+
export type RecalledMemory = {
|
|
14
|
+
id: string;
|
|
15
|
+
projectId: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
summary: string;
|
|
18
|
+
body: string | null;
|
|
19
|
+
at: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class ContextPack {
|
|
23
|
+
constructor(
|
|
24
|
+
readonly episodic: RecalledMemory[],
|
|
25
|
+
readonly semantic: Array<{ id: string; title: string; body: string }>,
|
|
26
|
+
readonly procedural: Array<{ id: string; name: string; description: string }> = [],
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
get isEmpty(): boolean {
|
|
30
|
+
return this.episodic.length === 0 && this.semantic.length === 0 && this.procedural.length === 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
toPromptString(): string {
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
if (this.procedural.length) {
|
|
36
|
+
lines.push("Reusable skills (procedural memory):");
|
|
37
|
+
for (const s of this.procedural) lines.push(`- ${s.name}: ${s.description}`);
|
|
38
|
+
}
|
|
39
|
+
if (this.semantic.length) {
|
|
40
|
+
lines.push("Cross-project knowledge (Main Brain):");
|
|
41
|
+
for (const s of this.semantic) lines.push(`- ${s.title}: ${s.body}`);
|
|
42
|
+
}
|
|
43
|
+
if (this.episodic.length) {
|
|
44
|
+
lines.push("Relevant project memory:");
|
|
45
|
+
for (const m of this.episodic) {
|
|
46
|
+
lines.push(`- [${m.kind}] ${m.summary}${m.body ? ` — ${m.body}` : ""}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type RecallOptions = {
|
|
54
|
+
query: string;
|
|
55
|
+
projectId: string;
|
|
56
|
+
allowCrossProject?: boolean;
|
|
57
|
+
k?: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export async function recall(opts: RecallOptions): Promise<ContextPack> {
|
|
61
|
+
const db = getDb();
|
|
62
|
+
const hits = await semanticSearch(opts.query, opts.k ?? 16);
|
|
63
|
+
|
|
64
|
+
const episodicIds = hits.filter((h) => h.refType === "episodic").map((h) => h.refId);
|
|
65
|
+
const semanticIds = hits.filter((h) => h.refType === "semantic").map((h) => h.refId);
|
|
66
|
+
const skillIds = hits.filter((h) => h.refType === "skill").map((h) => h.refId);
|
|
67
|
+
|
|
68
|
+
// Episodic: load, then apply the judgment layer.
|
|
69
|
+
const episodicRows = episodicIds.length
|
|
70
|
+
? db.select().from(memoryEntry).where(inArray(memoryEntry.id, episodicIds)).all()
|
|
71
|
+
: [];
|
|
72
|
+
const order = new Map(hits.map((h, i) => [h.refId, i]));
|
|
73
|
+
const episodic: RecalledMemory[] = episodicRows
|
|
74
|
+
.filter((m) => m.projectId === opts.projectId || opts.allowCrossProject === true)
|
|
75
|
+
.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0))
|
|
76
|
+
.map((m) => ({
|
|
77
|
+
id: m.id,
|
|
78
|
+
projectId: m.projectId,
|
|
79
|
+
kind: m.kind,
|
|
80
|
+
summary: m.summary,
|
|
81
|
+
body: m.body,
|
|
82
|
+
at: m.at,
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
// Semantic (Main Brain): always allowed.
|
|
86
|
+
const semanticRows = semanticIds.length
|
|
87
|
+
? db.select().from(semanticNote).where(inArray(semanticNote.id, semanticIds)).all()
|
|
88
|
+
: [];
|
|
89
|
+
const semantic = semanticRows.map((s) => ({ id: s.id, title: s.title, body: s.body }));
|
|
90
|
+
|
|
91
|
+
// Procedural (skills): always allowed (shared layer).
|
|
92
|
+
const skillRows = skillIds.length
|
|
93
|
+
? db.select().from(skill).where(inArray(skill.id, skillIds)).all()
|
|
94
|
+
: [];
|
|
95
|
+
const procedural = skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description }));
|
|
96
|
+
|
|
97
|
+
return new ContextPack(episodic, semantic, procedural);
|
|
98
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consolidation engine (Fasa 4, manual trigger) — episodic → semantic.
|
|
3
|
+
*
|
|
4
|
+
* Promotes patterns that RECUR across memories/projects into the Main Brain, the
|
|
5
|
+
* way human memory consolidates experience into general knowledge (PRD §2.2).
|
|
6
|
+
* Respects the judgment layer (§2.4): only generalized, anonymized patterns are
|
|
7
|
+
* promoted — raw project ids never go into the semantic body.
|
|
8
|
+
*
|
|
9
|
+
* Pattern extraction is pluggable. The default is deterministic (no LLM) so this
|
|
10
|
+
* runs offline; pass an LLM-backed extractor for richer synthesis.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { getDb } from "../db/client";
|
|
14
|
+
import { memoryEntry, semanticNote } from "../db/schema";
|
|
15
|
+
import { embedAndIndex } from "../memory/embed";
|
|
16
|
+
|
|
17
|
+
const STOPWORDS = new Set([
|
|
18
|
+
"the", "and", "for", "with", "that", "this", "from", "into", "when", "every",
|
|
19
|
+
"use", "uses", "used", "are", "was", "were", "will", "your", "you", "via",
|
|
20
|
+
"should", "must", "have", "has", "had", "not", "but", "its", "their", "them",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export type ClusterMember = { id: string; projectId: string; summary: string; body: string | null };
|
|
24
|
+
|
|
25
|
+
export type Pattern = { title: string; body: string; confidence: number };
|
|
26
|
+
|
|
27
|
+
export type PatternExtractor = (members: ClusterMember[], keyword: string) => Promise<Pattern>;
|
|
28
|
+
|
|
29
|
+
/** Deterministic, offline default: generalize the recurring members. */
|
|
30
|
+
export const deterministicExtractor: PatternExtractor = async (members, keyword) => {
|
|
31
|
+
const projects = new Set(members.map((m) => m.projectId));
|
|
32
|
+
const uniqueSummaries = [...new Set(members.map((m) => m.summary.trim()))];
|
|
33
|
+
const confidence = Math.min(95, 40 + members.length * 10 + projects.size * 10);
|
|
34
|
+
const body =
|
|
35
|
+
`Recurring across ${members.length} memories in ${projects.size} project(s). ` +
|
|
36
|
+
`Common theme: "${keyword}". Examples: ` +
|
|
37
|
+
uniqueSummaries.slice(0, 4).map((s) => `“${s}”`).join("; ") +
|
|
38
|
+
".";
|
|
39
|
+
return { title: `Pattern: ${keyword}`, body, confidence };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function keywordsOf(text: string): string[] {
|
|
43
|
+
return [
|
|
44
|
+
...new Set(
|
|
45
|
+
text
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.split(/\W+/)
|
|
48
|
+
.filter((w) => w.length >= 4 && !STOPWORDS.has(w)),
|
|
49
|
+
),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ConsolidateOptions = {
|
|
54
|
+
sinceDays?: number;
|
|
55
|
+
minClusterSize?: number;
|
|
56
|
+
maxNotes?: number;
|
|
57
|
+
extractor?: PatternExtractor;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type ConsolidateResult = {
|
|
61
|
+
scanned: number;
|
|
62
|
+
clusters: number;
|
|
63
|
+
notesCreated: number;
|
|
64
|
+
notes: Array<{ id: string; title: string; sourceProjects: string[]; confidence: number }>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export async function consolidate(opts: ConsolidateOptions = {}): Promise<ConsolidateResult> {
|
|
68
|
+
const db = getDb();
|
|
69
|
+
const minSize = opts.minClusterSize ?? 2;
|
|
70
|
+
const maxNotes = opts.maxNotes ?? 5;
|
|
71
|
+
const extract = opts.extractor ?? deterministicExtractor;
|
|
72
|
+
|
|
73
|
+
const since = opts.sinceDays ? Date.now() - opts.sinceDays * 86_400_000 : 0;
|
|
74
|
+
const rows = db.select().from(memoryEntry).all().filter((r) => r.at >= since);
|
|
75
|
+
|
|
76
|
+
// doc-frequency of each keyword + which projects it spans
|
|
77
|
+
const byKeyword = new Map<string, ClusterMember[]>();
|
|
78
|
+
for (const r of rows) {
|
|
79
|
+
const member: ClusterMember = { id: r.id, projectId: r.projectId, summary: r.summary, body: r.body };
|
|
80
|
+
for (const kw of keywordsOf(`${r.summary} ${r.body ?? ""}`)) {
|
|
81
|
+
const arr = byKeyword.get(kw) ?? [];
|
|
82
|
+
arr.push(member);
|
|
83
|
+
byKeyword.set(kw, arr);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Rank candidate clusters: prefer cross-project, then size.
|
|
88
|
+
const candidates = [...byKeyword.entries()]
|
|
89
|
+
.map(([kw, members]) => {
|
|
90
|
+
const dedup = [...new Map(members.map((m) => [m.id, m])).values()];
|
|
91
|
+
const projectSpan = new Set(dedup.map((m) => m.projectId)).size;
|
|
92
|
+
return { kw, members: dedup, projectSpan };
|
|
93
|
+
})
|
|
94
|
+
.filter((c) => c.members.length >= minSize)
|
|
95
|
+
.sort((a, b) => b.projectSpan - a.projectSpan || b.members.length - a.members.length);
|
|
96
|
+
|
|
97
|
+
const usedMemoryIds = new Set<string>();
|
|
98
|
+
const notes: ConsolidateResult["notes"] = [];
|
|
99
|
+
let clusters = 0;
|
|
100
|
+
|
|
101
|
+
for (const c of candidates) {
|
|
102
|
+
if (notes.length >= maxNotes) break;
|
|
103
|
+
// Skip near-duplicate clusters already mostly covered.
|
|
104
|
+
const fresh = c.members.filter((m) => !usedMemoryIds.has(m.id));
|
|
105
|
+
if (fresh.length < minSize) continue;
|
|
106
|
+
clusters += 1;
|
|
107
|
+
|
|
108
|
+
const pattern = await extract(c.members, c.kw);
|
|
109
|
+
const id = `sem-${randomUUID().slice(0, 8)}`;
|
|
110
|
+
const sourceProjects = [...new Set(c.members.map((m) => m.projectId))];
|
|
111
|
+
|
|
112
|
+
db.insert(semanticNote)
|
|
113
|
+
.values({
|
|
114
|
+
id,
|
|
115
|
+
title: pattern.title,
|
|
116
|
+
body: pattern.body,
|
|
117
|
+
sourceProjects,
|
|
118
|
+
confidence: pattern.confidence,
|
|
119
|
+
createdAt: Date.now(),
|
|
120
|
+
})
|
|
121
|
+
.run();
|
|
122
|
+
await embedAndIndex("semantic", id, `${pattern.title}\n${pattern.body}`);
|
|
123
|
+
|
|
124
|
+
for (const m of c.members) usedMemoryIds.add(m.id);
|
|
125
|
+
notes.push({ id, title: pattern.title, sourceProjects, confidence: pattern.confidence });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { scanned: rows.length, clusters, notesCreated: notes.length, notes };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function listSemanticNotes(): Array<typeof semanticNote.$inferSelect> {
|
|
132
|
+
return getDb().select().from(semanticNote).all();
|
|
133
|
+
}
|