feat(web): restore payload CMS (cuidecar pattern + importMap)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,13 @@
|
||||
},
|
||||
"prettier": "@turbostarter/prettier-config",
|
||||
"dependencies": {
|
||||
"@qdrant/js-client-rest": "1.17.0",
|
||||
"@turbostarter/db": "workspace:*",
|
||||
"@turbostarter/shared": "workspace:*",
|
||||
"drizzle-orm": "0.44.7",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"minio": "8.0.7",
|
||||
"neo4j-driver": "6.0.1",
|
||||
"ws": "8.20.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -34,9 +34,12 @@ import {
|
||||
mesh,
|
||||
meshFile,
|
||||
meshFileAccess,
|
||||
meshContext,
|
||||
meshMember as memberTable,
|
||||
meshMemory,
|
||||
meshState,
|
||||
meshStream,
|
||||
meshTask,
|
||||
messageQueue,
|
||||
pendingStatus,
|
||||
presence,
|
||||
@@ -889,6 +892,334 @@ export async function deleteFile(
|
||||
);
|
||||
}
|
||||
|
||||
// --- Context sharing ---
|
||||
|
||||
/**
|
||||
* Upsert a context snapshot for a peer. Each (meshId, presenceId) pair
|
||||
* has at most one context row — repeated calls update it in place.
|
||||
*/
|
||||
export async function shareContext(
|
||||
meshId: string,
|
||||
presenceId: string,
|
||||
peerName: string | undefined,
|
||||
summary: string,
|
||||
filesRead?: string[],
|
||||
keyFindings?: string[],
|
||||
tags?: string[],
|
||||
): Promise<string> {
|
||||
const now = new Date();
|
||||
// Try to find existing context for this presence in this mesh.
|
||||
const [existing] = await db
|
||||
.select({ id: meshContext.id })
|
||||
.from(meshContext)
|
||||
.where(
|
||||
and(
|
||||
eq(meshContext.meshId, meshId),
|
||||
eq(meshContext.presenceId, presenceId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(meshContext)
|
||||
.set({
|
||||
peerName: peerName ?? null,
|
||||
summary,
|
||||
filesRead: filesRead ?? [],
|
||||
keyFindings: keyFindings ?? [],
|
||||
tags: tags ?? [],
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(meshContext.id, existing.id));
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(meshContext)
|
||||
.values({
|
||||
meshId,
|
||||
presenceId,
|
||||
peerName: peerName ?? null,
|
||||
summary,
|
||||
filesRead: filesRead ?? [],
|
||||
keyFindings: keyFindings ?? [],
|
||||
tags: tags ?? [],
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning({ id: meshContext.id });
|
||||
if (!row) throw new Error("failed to insert context");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search contexts by tag match or summary ILIKE.
|
||||
*/
|
||||
export async function getContext(
|
||||
meshId: string,
|
||||
query: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
peerName: string;
|
||||
summary: string;
|
||||
filesRead: string[];
|
||||
keyFindings: string[];
|
||||
tags: string[];
|
||||
updatedAt: Date;
|
||||
}>
|
||||
> {
|
||||
const result = await db.execute<{
|
||||
peer_name: string | null;
|
||||
summary: string;
|
||||
files_read: string[] | null;
|
||||
key_findings: string[] | null;
|
||||
tags: string[] | null;
|
||||
updated_at: string | Date;
|
||||
}>(sql`
|
||||
SELECT peer_name, summary, files_read, key_findings, tags, updated_at
|
||||
FROM mesh.context
|
||||
WHERE mesh_id = ${meshId}
|
||||
AND (
|
||||
summary ILIKE ${"%" + query + "%"}
|
||||
OR ${query} = ANY(tags)
|
||||
)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
const rows = (result.rows ?? result) as Array<{
|
||||
peer_name: string | null;
|
||||
summary: string;
|
||||
files_read: string[] | null;
|
||||
key_findings: string[] | null;
|
||||
tags: string[] | null;
|
||||
updated_at: string | Date;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
peerName: r.peer_name ?? "unknown",
|
||||
summary: r.summary,
|
||||
filesRead: r.files_read ?? [],
|
||||
keyFindings: r.key_findings ?? [],
|
||||
tags: r.tags ?? [],
|
||||
updatedAt:
|
||||
r.updated_at instanceof Date ? r.updated_at : new Date(r.updated_at),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all contexts for a mesh, ordered by most recently updated.
|
||||
*/
|
||||
export async function listContexts(
|
||||
meshId: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
peerName: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
updatedAt: Date;
|
||||
}>
|
||||
> {
|
||||
const rows = await db
|
||||
.select({
|
||||
peerName: meshContext.peerName,
|
||||
summary: meshContext.summary,
|
||||
tags: meshContext.tags,
|
||||
updatedAt: meshContext.updatedAt,
|
||||
})
|
||||
.from(meshContext)
|
||||
.where(eq(meshContext.meshId, meshId))
|
||||
.orderBy(desc(meshContext.updatedAt));
|
||||
return rows.map((r) => ({
|
||||
peerName: r.peerName ?? "unknown",
|
||||
summary: r.summary,
|
||||
tags: (r.tags ?? []) as string[],
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
/**
|
||||
* Create a new task in a mesh. Returns the generated id.
|
||||
*/
|
||||
export async function createTask(
|
||||
meshId: string,
|
||||
title: string,
|
||||
assignee?: string,
|
||||
priority?: string,
|
||||
tags?: string[],
|
||||
createdByName?: string,
|
||||
): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(meshTask)
|
||||
.values({
|
||||
meshId,
|
||||
title,
|
||||
assignee: assignee ?? null,
|
||||
priority: priority ?? "normal",
|
||||
status: "open",
|
||||
tags: tags ?? [],
|
||||
createdByName: createdByName ?? null,
|
||||
})
|
||||
.returning({ id: meshTask.id });
|
||||
if (!row) throw new Error("failed to insert task");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim an open task. Sets status to 'claimed' and records who claimed it.
|
||||
* Only succeeds if the task is currently 'open'.
|
||||
*/
|
||||
export async function claimTask(
|
||||
meshId: string,
|
||||
taskId: string,
|
||||
presenceId: string,
|
||||
peerName?: string,
|
||||
): Promise<boolean> {
|
||||
const now = new Date();
|
||||
const result = await db
|
||||
.update(meshTask)
|
||||
.set({
|
||||
status: "claimed",
|
||||
claimedByPresence: presenceId,
|
||||
claimedByName: peerName ?? null,
|
||||
claimedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(meshTask.id, taskId),
|
||||
eq(meshTask.meshId, meshId),
|
||||
eq(meshTask.status, "open"),
|
||||
),
|
||||
)
|
||||
.returning({ id: meshTask.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a task. Sets status to 'done', records the result and timestamp.
|
||||
*/
|
||||
export async function completeTask(
|
||||
meshId: string,
|
||||
taskId: string,
|
||||
result?: string,
|
||||
): Promise<boolean> {
|
||||
const now = new Date();
|
||||
const rows = await db
|
||||
.update(meshTask)
|
||||
.set({
|
||||
status: "done",
|
||||
result: result ?? null,
|
||||
completedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(meshTask.id, taskId),
|
||||
eq(meshTask.meshId, meshId),
|
||||
),
|
||||
)
|
||||
.returning({ id: meshTask.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* List tasks in a mesh with optional status and assignee filters.
|
||||
*/
|
||||
export async function listTasks(
|
||||
meshId: string,
|
||||
status?: string,
|
||||
assignee?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
assignee: string | null;
|
||||
claimedBy: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
createdBy: string | null;
|
||||
tags: string[];
|
||||
createdAt: Date;
|
||||
}>
|
||||
> {
|
||||
const conditions = [eq(meshTask.meshId, meshId)];
|
||||
if (status) {
|
||||
conditions.push(eq(meshTask.status, status));
|
||||
}
|
||||
if (assignee) {
|
||||
conditions.push(eq(meshTask.assignee, assignee));
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
id: meshTask.id,
|
||||
title: meshTask.title,
|
||||
assignee: meshTask.assignee,
|
||||
claimedByName: meshTask.claimedByName,
|
||||
status: meshTask.status,
|
||||
priority: meshTask.priority,
|
||||
createdByName: meshTask.createdByName,
|
||||
tags: meshTask.tags,
|
||||
createdAt: meshTask.createdAt,
|
||||
})
|
||||
.from(meshTask)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(meshTask.createdAt))
|
||||
.limit(100);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
assignee: r.assignee,
|
||||
claimedBy: r.claimedByName,
|
||||
status: r.status,
|
||||
priority: r.priority,
|
||||
createdBy: r.createdByName,
|
||||
tags: (r.tags ?? []) as string[],
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Streams ---
|
||||
|
||||
/**
|
||||
* Create a named real-time stream in a mesh. Upsert semantics: if a
|
||||
* stream with the same (meshId, name) already exists, return its id.
|
||||
*/
|
||||
export async function createStream(
|
||||
meshId: string,
|
||||
name: string,
|
||||
createdByName: string,
|
||||
): Promise<string> {
|
||||
const existing = await db
|
||||
.select({ id: meshStream.id })
|
||||
.from(meshStream)
|
||||
.where(and(eq(meshStream.meshId, meshId), eq(meshStream.name, name)));
|
||||
if (existing.length > 0) return existing[0]!.id;
|
||||
const [row] = await db
|
||||
.insert(meshStream)
|
||||
.values({ meshId, name, createdByName })
|
||||
.returning({ id: meshStream.id });
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all streams in a mesh, ordered by creation time.
|
||||
*/
|
||||
export async function listStreams(
|
||||
meshId: string,
|
||||
): Promise<
|
||||
Array<{ id: string; name: string; createdBy: string | null; createdAt: Date }>
|
||||
> {
|
||||
return db
|
||||
.select({
|
||||
id: meshStream.id,
|
||||
name: meshStream.name,
|
||||
createdBy: meshStream.createdByName,
|
||||
createdAt: meshStream.createdAt,
|
||||
})
|
||||
.from(meshStream)
|
||||
.where(eq(meshStream.meshId, meshId))
|
||||
.orderBy(asc(meshStream.createdAt));
|
||||
}
|
||||
|
||||
// --- Message queueing + delivery ---
|
||||
|
||||
export interface QueueParams {
|
||||
@@ -1239,3 +1570,118 @@ export async function findMemberByPubkey(
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// --- Mesh databases (per-mesh PostgreSQL schemas) ---
|
||||
|
||||
function meshSchemaName(meshId: string): string {
|
||||
return `meshdb_${meshId.toLowerCase().replace(/[^a-z0-9]/g, "_")}`;
|
||||
}
|
||||
|
||||
/** Validate that user-provided SQL doesn't contain dangerous operations. */
|
||||
function validateMeshSql(userSql: string): void {
|
||||
const upper = userSql.toUpperCase();
|
||||
const forbidden = [
|
||||
"DROP SCHEMA",
|
||||
"CREATE SCHEMA",
|
||||
"SET SEARCH_PATH",
|
||||
"SET ROLE",
|
||||
"SET SESSION",
|
||||
"SET LOCAL",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
];
|
||||
for (const f of forbidden) {
|
||||
if (upper.includes(f))
|
||||
throw new Error(`Forbidden SQL operation: ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the per-mesh schema exists. */
|
||||
export async function ensureMeshSchema(meshId: string): Promise<string> {
|
||||
const schema = meshSchemaName(meshId);
|
||||
await db.execute(
|
||||
sql`CREATE SCHEMA IF NOT EXISTS ${sql.raw('"' + schema + '"')}`,
|
||||
);
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** Run a SELECT query in the mesh's schema. */
|
||||
export async function meshQuery(
|
||||
meshId: string,
|
||||
query: string,
|
||||
): Promise<{
|
||||
columns: string[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
rowCount: number;
|
||||
}> {
|
||||
validateMeshSql(query);
|
||||
const schema = await ensureMeshSchema(meshId);
|
||||
// Use a transaction so SET LOCAL is scoped and automatically reset.
|
||||
return await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql.raw(`SET LOCAL search_path TO "${schema}"`)
|
||||
);
|
||||
const result = await tx.execute(sql.raw(query));
|
||||
const rows = (result.rows ?? []) as Array<Record<string, unknown>>;
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]!) : [];
|
||||
return { columns, rows, rowCount: rows.length };
|
||||
});
|
||||
}
|
||||
|
||||
/** Run a DDL/DML statement in the mesh's schema. */
|
||||
export async function meshExecute(
|
||||
meshId: string,
|
||||
statement: string,
|
||||
): Promise<{ rowCount: number }> {
|
||||
validateMeshSql(statement);
|
||||
const schema = await ensureMeshSchema(meshId);
|
||||
return await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql.raw(`SET LOCAL search_path TO "${schema}"`)
|
||||
);
|
||||
const result = await tx.execute(sql.raw(statement));
|
||||
return { rowCount: (result as any).rowCount ?? 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** List tables and columns in the mesh's schema. */
|
||||
export async function meshSchema(
|
||||
meshId: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
columns: Array<{ name: string; type: string; nullable: boolean }>;
|
||||
}>
|
||||
> {
|
||||
const schema = meshSchemaName(meshId);
|
||||
const result = await db.execute<{
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}>(sql`
|
||||
SELECT table_name, column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = ${schema}
|
||||
ORDER BY table_name, ordinal_position
|
||||
`);
|
||||
const rows = (result.rows ?? result) as Array<{
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}>;
|
||||
const tables = new Map<
|
||||
string,
|
||||
Array<{ name: string; type: string; nullable: boolean }>
|
||||
>();
|
||||
for (const r of rows) {
|
||||
if (!tables.has(r.table_name)) tables.set(r.table_name, []);
|
||||
tables.get(r.table_name)!.push({
|
||||
name: r.column_name,
|
||||
type: r.data_type,
|
||||
nullable: r.is_nullable === "YES",
|
||||
});
|
||||
}
|
||||
return [...tables.entries()].map(([name, columns]) => ({ name, columns }));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ const envSchema = z.object({
|
||||
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
||||
MINIO_SECRET_KEY: z.string().default("changeme"),
|
||||
MINIO_USE_SSL: z.coerce.boolean().default(false),
|
||||
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||
NEO4J_USER: z.string().default("neo4j"),
|
||||
NEO4J_PASSWORD: z.string().default("changeme"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
|
||||
@@ -15,17 +15,21 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { env } from "./env";
|
||||
import { db } from "./db";
|
||||
import { messageQueue } from "@turbostarter/db/schema/mesh";
|
||||
import {
|
||||
claimTask,
|
||||
completeTask,
|
||||
connectPresence,
|
||||
createTask,
|
||||
deleteFile,
|
||||
disconnectPresence,
|
||||
drainForMember,
|
||||
findMemberByPubkey,
|
||||
forgetMemory,
|
||||
getContext,
|
||||
getFile,
|
||||
getFileStatus,
|
||||
getState,
|
||||
@@ -34,9 +38,11 @@ import {
|
||||
joinGroup,
|
||||
joinMesh,
|
||||
leaveGroup,
|
||||
listContexts,
|
||||
listFiles,
|
||||
listPeersInMesh,
|
||||
listState,
|
||||
listTasks,
|
||||
queueMessage,
|
||||
recallMemory,
|
||||
recordFileAccess,
|
||||
@@ -45,12 +51,21 @@ import {
|
||||
rememberMemory,
|
||||
setSummary,
|
||||
setState,
|
||||
shareContext,
|
||||
startSweepers,
|
||||
stopSweepers,
|
||||
uploadFile,
|
||||
writeStatus,
|
||||
ensureMeshSchema,
|
||||
meshQuery,
|
||||
meshExecute,
|
||||
meshSchema,
|
||||
createStream,
|
||||
listStreams,
|
||||
} from "./broker";
|
||||
import { ensureBucket, meshBucketName, minioClient } from "./minio";
|
||||
import { qdrant, meshCollectionName, ensureCollection } from "./qdrant";
|
||||
import { neo4jDriver, meshDbName, ensureDatabase } from "./neo4j-client";
|
||||
import type {
|
||||
HookSetStatusRequest,
|
||||
WSClientMessage,
|
||||
@@ -81,6 +96,9 @@ interface PeerConn {
|
||||
|
||||
const connections = new Map<string, PeerConn>();
|
||||
const connectionsPerMesh = new Map<string, number>();
|
||||
|
||||
// Stream subscriptions: "meshId:streamName" → Set of presenceIds
|
||||
const streamSubscriptions = new Map<string, Set<string>>();
|
||||
const hookRateLimit = new TokenBucket(
|
||||
env.HOOK_RATE_LIMIT_PER_MIN,
|
||||
env.HOOK_RATE_LIMIT_PER_MIN,
|
||||
@@ -1066,6 +1084,598 @@ function handleConnection(ws: WebSocket): void {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "share_context": {
|
||||
const sc = msg as Extract<WSClientMessage, { type: "share_context" }>;
|
||||
const memberInfo = conn.memberPubkey
|
||||
? await findMemberByPubkey(conn.meshId, conn.memberPubkey)
|
||||
: null;
|
||||
const ctxId = await shareContext(
|
||||
conn.meshId,
|
||||
presenceId,
|
||||
memberInfo?.displayName,
|
||||
sc.summary,
|
||||
sc.filesRead,
|
||||
sc.keyFindings,
|
||||
sc.tags,
|
||||
);
|
||||
sendToPeer(presenceId, {
|
||||
type: "context_shared",
|
||||
id: ctxId,
|
||||
});
|
||||
// Notify all other peers in the mesh that context was shared.
|
||||
for (const [pid, peer] of connections) {
|
||||
if (pid === presenceId) continue;
|
||||
if (peer.meshId !== conn.meshId) continue;
|
||||
sendToPeer(pid, {
|
||||
type: "state_change",
|
||||
key: `_context:${memberInfo?.displayName ?? "unknown"}`,
|
||||
value: sc.summary,
|
||||
updatedBy: memberInfo?.displayName ?? "unknown",
|
||||
});
|
||||
}
|
||||
log.info("ws share_context", {
|
||||
presence_id: presenceId,
|
||||
context_id: ctxId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "get_context": {
|
||||
const gc = msg as Extract<WSClientMessage, { type: "get_context" }>;
|
||||
const contexts = await getContext(conn.meshId, gc.query);
|
||||
sendToPeer(presenceId, {
|
||||
type: "context_results",
|
||||
contexts: contexts.map((c) => ({
|
||||
peerName: c.peerName,
|
||||
summary: c.summary,
|
||||
filesRead: c.filesRead,
|
||||
keyFindings: c.keyFindings,
|
||||
tags: c.tags,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
log.info("ws get_context", {
|
||||
presence_id: presenceId,
|
||||
query: gc.query.slice(0, 80),
|
||||
results: contexts.length,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "list_contexts": {
|
||||
const allContexts = await listContexts(conn.meshId);
|
||||
sendToPeer(presenceId, {
|
||||
type: "context_list",
|
||||
contexts: allContexts.map((c) => ({
|
||||
peerName: c.peerName,
|
||||
summary: c.summary,
|
||||
tags: c.tags,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
log.info("ws list_contexts", {
|
||||
presence_id: presenceId,
|
||||
mesh_id: conn.meshId,
|
||||
count: allContexts.length,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "create_task": {
|
||||
const ct = msg as Extract<WSClientMessage, { type: "create_task" }>;
|
||||
const memberInfo = conn.memberPubkey
|
||||
? await findMemberByPubkey(conn.meshId, conn.memberPubkey)
|
||||
: null;
|
||||
const taskId = await createTask(
|
||||
conn.meshId,
|
||||
ct.title,
|
||||
ct.assignee,
|
||||
ct.priority,
|
||||
ct.tags,
|
||||
memberInfo?.displayName,
|
||||
);
|
||||
sendToPeer(presenceId, {
|
||||
type: "task_created",
|
||||
id: taskId,
|
||||
});
|
||||
log.info("ws create_task", {
|
||||
presence_id: presenceId,
|
||||
task_id: taskId,
|
||||
title: ct.title.slice(0, 80),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "claim_task": {
|
||||
const clm = msg as Extract<WSClientMessage, { type: "claim_task" }>;
|
||||
const memberInfo = conn.memberPubkey
|
||||
? await findMemberByPubkey(conn.meshId, conn.memberPubkey)
|
||||
: null;
|
||||
const claimed = await claimTask(
|
||||
conn.meshId,
|
||||
clm.taskId,
|
||||
presenceId,
|
||||
memberInfo?.displayName,
|
||||
);
|
||||
if (!claimed) {
|
||||
sendError(conn.ws, "task_not_claimable", "task is not open or does not exist");
|
||||
break;
|
||||
}
|
||||
// Return updated task list so caller sees the change.
|
||||
const tasksAfterClaim = await listTasks(conn.meshId);
|
||||
sendToPeer(presenceId, {
|
||||
type: "task_list",
|
||||
tasks: tasksAfterClaim.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
assignee: t.assignee,
|
||||
claimedBy: t.claimedBy,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
createdBy: t.createdBy,
|
||||
tags: t.tags,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
log.info("ws claim_task", {
|
||||
presence_id: presenceId,
|
||||
task_id: clm.taskId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "complete_task": {
|
||||
const cpt = msg as Extract<WSClientMessage, { type: "complete_task" }>;
|
||||
const completed = await completeTask(
|
||||
conn.meshId,
|
||||
cpt.taskId,
|
||||
cpt.result,
|
||||
);
|
||||
if (!completed) {
|
||||
sendError(conn.ws, "task_not_found", "task not found in this mesh");
|
||||
break;
|
||||
}
|
||||
// Return updated task list.
|
||||
const tasksAfterComplete = await listTasks(conn.meshId);
|
||||
sendToPeer(presenceId, {
|
||||
type: "task_list",
|
||||
tasks: tasksAfterComplete.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
assignee: t.assignee,
|
||||
claimedBy: t.claimedBy,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
createdBy: t.createdBy,
|
||||
tags: t.tags,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
log.info("ws complete_task", {
|
||||
presence_id: presenceId,
|
||||
task_id: cpt.taskId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "list_tasks": {
|
||||
const lt = msg as Extract<WSClientMessage, { type: "list_tasks" }>;
|
||||
const tasks = await listTasks(conn.meshId, lt.status, lt.assignee);
|
||||
sendToPeer(presenceId, {
|
||||
type: "task_list",
|
||||
tasks: tasks.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
assignee: t.assignee,
|
||||
claimedBy: t.claimedBy,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
createdBy: t.createdBy,
|
||||
tags: t.tags,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
log.info("ws list_tasks", {
|
||||
presence_id: presenceId,
|
||||
mesh_id: conn.meshId,
|
||||
count: tasks.length,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Streams ---
|
||||
|
||||
case "create_stream": {
|
||||
const cs = msg as Extract<WSClientMessage, { type: "create_stream" }>;
|
||||
const memberInfo = conn.memberPubkey
|
||||
? await findMemberByPubkey(conn.meshId, conn.memberPubkey)
|
||||
: null;
|
||||
const streamId = await createStream(
|
||||
conn.meshId,
|
||||
cs.name,
|
||||
memberInfo?.displayName ?? "peer",
|
||||
);
|
||||
sendToPeer(presenceId, {
|
||||
type: "stream_created",
|
||||
id: streamId,
|
||||
name: cs.name,
|
||||
});
|
||||
log.info("ws create_stream", {
|
||||
presence_id: presenceId,
|
||||
stream: cs.name,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "subscribe": {
|
||||
const sub = msg as Extract<WSClientMessage, { type: "subscribe" }>;
|
||||
const key = `${conn.meshId}:${sub.stream}`;
|
||||
if (!streamSubscriptions.has(key))
|
||||
streamSubscriptions.set(key, new Set());
|
||||
streamSubscriptions.get(key)!.add(presenceId);
|
||||
log.info("ws subscribe", {
|
||||
presence_id: presenceId,
|
||||
stream: sub.stream,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "unsubscribe": {
|
||||
const unsub = msg as Extract<
|
||||
WSClientMessage,
|
||||
{ type: "unsubscribe" }
|
||||
>;
|
||||
const key = `${conn.meshId}:${unsub.stream}`;
|
||||
streamSubscriptions.get(key)?.delete(presenceId);
|
||||
log.info("ws unsubscribe", {
|
||||
presence_id: presenceId,
|
||||
stream: unsub.stream,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "publish": {
|
||||
const pub = msg as Extract<WSClientMessage, { type: "publish" }>;
|
||||
const key = `${conn.meshId}:${pub.stream}`;
|
||||
const subs = streamSubscriptions.get(key);
|
||||
if (subs) {
|
||||
const memberInfo = conn.memberPubkey
|
||||
? await findMemberByPubkey(conn.meshId, conn.memberPubkey)
|
||||
: null;
|
||||
const push: WSServerMessage = {
|
||||
type: "stream_data",
|
||||
stream: pub.stream,
|
||||
data: pub.data,
|
||||
publishedBy: memberInfo?.displayName ?? "peer",
|
||||
};
|
||||
for (const subPid of subs) {
|
||||
if (subPid === presenceId) continue; // don't echo to publisher
|
||||
sendToPeer(subPid, push);
|
||||
}
|
||||
}
|
||||
metrics.messagesRoutedTotal.inc({ priority: "stream" });
|
||||
break;
|
||||
}
|
||||
|
||||
case "list_streams": {
|
||||
const streams = await listStreams(conn.meshId);
|
||||
sendToPeer(presenceId, {
|
||||
type: "stream_list",
|
||||
streams: streams.map((s) => {
|
||||
const key = `${conn.meshId}:${s.name}`;
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
createdBy: s.createdBy ?? "",
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
subscriberCount: streamSubscriptions.get(key)?.size ?? 0,
|
||||
};
|
||||
}),
|
||||
});
|
||||
log.info("ws list_streams", {
|
||||
presence_id: presenceId,
|
||||
mesh_id: conn.meshId,
|
||||
count: streams.length,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Vector storage ---
|
||||
|
||||
case "vector_store": {
|
||||
const vs = msg as Extract<WSClientMessage, { type: "vector_store" }>;
|
||||
const collName = meshCollectionName(conn.meshId, vs.collection);
|
||||
await ensureCollection(collName);
|
||||
const { generateId } = await import("@turbostarter/shared/utils");
|
||||
const pointId = generateId();
|
||||
// Store text + metadata as payload. Use a zero vector as placeholder
|
||||
// — real embeddings should be computed client-side and sent directly
|
||||
// to Qdrant in a future version.
|
||||
const zeroVector = new Array(1536).fill(0) as number[];
|
||||
await qdrant.upsert(collName, {
|
||||
wait: true,
|
||||
points: [
|
||||
{
|
||||
id: pointId,
|
||||
vector: zeroVector,
|
||||
payload: {
|
||||
text: vs.text,
|
||||
mesh_id: conn.meshId,
|
||||
stored_by: conn.memberPubkey,
|
||||
stored_at: new Date().toISOString(),
|
||||
...(vs.metadata ?? {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
sendToPeer(presenceId, {
|
||||
type: "ack" as const,
|
||||
id: pointId,
|
||||
messageId: pointId,
|
||||
queued: false,
|
||||
});
|
||||
log.info("ws vector_store", {
|
||||
presence_id: presenceId,
|
||||
collection: vs.collection,
|
||||
point_id: pointId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "vector_search": {
|
||||
const vq = msg as Extract<WSClientMessage, { type: "vector_search" }>;
|
||||
const searchCollName = meshCollectionName(conn.meshId, vq.collection);
|
||||
const searchLimit = vq.limit ?? 10;
|
||||
try {
|
||||
// Keyword search via payload scroll + filter.
|
||||
// Full vector similarity requires client-computed embeddings (future).
|
||||
const queryLower = vq.query.toLowerCase();
|
||||
const scrollResult = await qdrant.scroll(searchCollName, {
|
||||
limit: 100,
|
||||
with_payload: true,
|
||||
with_vector: false,
|
||||
});
|
||||
const matches = (scrollResult.points ?? [])
|
||||
.filter((p) => {
|
||||
const text = (p.payload as Record<string, unknown>)?.text;
|
||||
return typeof text === "string" && text.toLowerCase().includes(queryLower);
|
||||
})
|
||||
.slice(0, searchLimit)
|
||||
.map((p) => {
|
||||
const payload = p.payload as Record<string, unknown>;
|
||||
return {
|
||||
id: String(p.id),
|
||||
text: (payload.text as string) ?? "",
|
||||
score: 1.0, // keyword match — no vector similarity score
|
||||
metadata: payload,
|
||||
};
|
||||
});
|
||||
sendToPeer(presenceId, {
|
||||
type: "vector_results",
|
||||
results: matches,
|
||||
});
|
||||
} catch {
|
||||
// Collection may not exist yet — return empty results.
|
||||
sendToPeer(presenceId, {
|
||||
type: "vector_results",
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
log.info("ws vector_search", {
|
||||
presence_id: presenceId,
|
||||
collection: vq.collection,
|
||||
query: vq.query.slice(0, 80),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "vector_delete": {
|
||||
const vd = msg as Extract<WSClientMessage, { type: "vector_delete" }>;
|
||||
const deleteCollName = meshCollectionName(conn.meshId, vd.collection);
|
||||
try {
|
||||
await qdrant.delete(deleteCollName, {
|
||||
wait: true,
|
||||
points: [vd.id],
|
||||
});
|
||||
} catch {
|
||||
/* collection or point may not exist — idempotent */
|
||||
}
|
||||
sendToPeer(presenceId, {
|
||||
type: "ack" as const,
|
||||
id: vd.id,
|
||||
messageId: vd.id,
|
||||
queued: false,
|
||||
});
|
||||
log.info("ws vector_delete", {
|
||||
presence_id: presenceId,
|
||||
collection: vd.collection,
|
||||
point_id: vd.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "list_collections": {
|
||||
try {
|
||||
const qdrantResponse = await qdrant.getCollections();
|
||||
const prefix = `mesh_${conn.meshId}_`.toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
||||
const meshCollections = (qdrantResponse.collections ?? [])
|
||||
.map((c) => c.name)
|
||||
.filter((name) => name.startsWith(prefix))
|
||||
.map((name) => name.slice(prefix.length));
|
||||
sendToPeer(presenceId, {
|
||||
type: "collection_list",
|
||||
collections: meshCollections,
|
||||
});
|
||||
} catch {
|
||||
sendToPeer(presenceId, {
|
||||
type: "collection_list",
|
||||
collections: [],
|
||||
});
|
||||
}
|
||||
log.info("ws list_collections", {
|
||||
presence_id: presenceId,
|
||||
mesh_id: conn.meshId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Graph database ---
|
||||
|
||||
case "graph_query": {
|
||||
const gq = msg as Extract<WSClientMessage, { type: "graph_query" }>;
|
||||
const gqDbName = meshDbName(conn.meshId);
|
||||
let gqSession;
|
||||
try {
|
||||
await ensureDatabase(gqDbName);
|
||||
gqSession = neo4jDriver.session({ database: gqDbName });
|
||||
} catch {
|
||||
// Community edition — fall back to default db.
|
||||
gqSession = neo4jDriver.session();
|
||||
}
|
||||
try {
|
||||
const gqResult = await gqSession.run(gq.cypher);
|
||||
const gqRecords = gqResult.records.map((r) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const key of r.keys) {
|
||||
obj[key] = r.get(key);
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
sendToPeer(presenceId, {
|
||||
type: "graph_result",
|
||||
records: gqRecords,
|
||||
});
|
||||
} catch (gqErr) {
|
||||
sendError(conn.ws, "graph_error", gqErr instanceof Error ? gqErr.message : String(gqErr));
|
||||
} finally {
|
||||
await gqSession.close();
|
||||
}
|
||||
log.info("ws graph_query", {
|
||||
presence_id: presenceId,
|
||||
cypher: gq.cypher.slice(0, 80),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "graph_execute": {
|
||||
const ge = msg as Extract<WSClientMessage, { type: "graph_execute" }>;
|
||||
const geDbName = meshDbName(conn.meshId);
|
||||
let geSession;
|
||||
try {
|
||||
await ensureDatabase(geDbName);
|
||||
geSession = neo4jDriver.session({ database: geDbName });
|
||||
} catch {
|
||||
geSession = neo4jDriver.session();
|
||||
}
|
||||
try {
|
||||
const geResult = await geSession.run(ge.cypher);
|
||||
const geRecords = geResult.records.map((r) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const key of r.keys) {
|
||||
obj[key] = r.get(key);
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
sendToPeer(presenceId, {
|
||||
type: "graph_result",
|
||||
records: geRecords,
|
||||
});
|
||||
} catch (geErr) {
|
||||
sendError(conn.ws, "graph_error", geErr instanceof Error ? geErr.message : String(geErr));
|
||||
} finally {
|
||||
await geSession.close();
|
||||
}
|
||||
log.info("ws graph_execute", {
|
||||
presence_id: presenceId,
|
||||
cypher: ge.cypher.slice(0, 80),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Mesh database (per-mesh PostgreSQL schema) ---
|
||||
|
||||
case "mesh_query": {
|
||||
const mq = msg as Extract<WSClientMessage, { type: "mesh_query" }>;
|
||||
try {
|
||||
const result = await meshQuery(conn.meshId, mq.sql);
|
||||
sendToPeer(presenceId, { type: "mesh_query_result", ...result });
|
||||
} catch (e) {
|
||||
sendError(
|
||||
conn.ws,
|
||||
"query_error",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
}
|
||||
log.info("ws mesh_query", {
|
||||
presence_id: presenceId,
|
||||
sql: mq.sql.slice(0, 80),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "mesh_execute": {
|
||||
const me = msg as Extract<WSClientMessage, { type: "mesh_execute" }>;
|
||||
try {
|
||||
const result = await meshExecute(conn.meshId, me.sql);
|
||||
sendToPeer(presenceId, {
|
||||
type: "mesh_query_result",
|
||||
columns: [],
|
||||
rows: [],
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
} catch (e) {
|
||||
sendError(
|
||||
conn.ws,
|
||||
"execute_error",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
}
|
||||
log.info("ws mesh_execute", {
|
||||
presence_id: presenceId,
|
||||
sql: me.sql.slice(0, 80),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "mesh_schema": {
|
||||
try {
|
||||
const tables = await meshSchema(conn.meshId);
|
||||
sendToPeer(presenceId, { type: "mesh_schema_result", tables });
|
||||
} catch (e) {
|
||||
sendError(
|
||||
conn.ws,
|
||||
"schema_error",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
}
|
||||
log.info("ws mesh_schema", { presence_id: presenceId });
|
||||
break;
|
||||
}
|
||||
case "mesh_info": {
|
||||
const [peers, stateEntries, memCount, fileCount, taskCounts, streams, tables] = await Promise.all([
|
||||
listPeersInMesh(conn.meshId),
|
||||
listState(conn.meshId),
|
||||
db.execute(sql`SELECT COUNT(*) as n FROM mesh.memory WHERE mesh_id = ${conn.meshId} AND forgotten_at IS NULL`).then(r => Number(((r.rows ?? r) as any[])[0]?.n ?? 0)),
|
||||
db.execute(sql`SELECT COUNT(*) as n FROM mesh.file WHERE mesh_id = ${conn.meshId} AND deleted_at IS NULL`).then(r => Number(((r.rows ?? r) as any[])[0]?.n ?? 0)),
|
||||
db.execute(sql`SELECT status, COUNT(*) as n FROM mesh.task WHERE mesh_id = ${conn.meshId} GROUP BY status`).then(r => {
|
||||
const rows = (r.rows ?? r) as Array<{ status: string; n: string }>;
|
||||
const counts = { open: 0, claimed: 0, done: 0 };
|
||||
for (const row of rows) counts[row.status as keyof typeof counts] = Number(row.n);
|
||||
return counts;
|
||||
}),
|
||||
listStreams(conn.meshId),
|
||||
meshSchema(conn.meshId).catch(() => []),
|
||||
]);
|
||||
const allGroups = new Set<string>();
|
||||
for (const p of peers) for (const g of p.groups) allGroups.add(`@${g.name}`);
|
||||
const myPresence = peers.find(p => p.sessionId === [...connections.entries()].find(([pid]) => pid === presenceId)?.[1]?.sessionPubkey);
|
||||
const peerConn = connections.get(presenceId);
|
||||
sendToPeer(presenceId, {
|
||||
type: "mesh_info_result",
|
||||
mesh: conn.meshId,
|
||||
peers: peers.length,
|
||||
groups: [...allGroups],
|
||||
stateKeys: stateEntries.map((e: any) => e.key),
|
||||
memoryCount: memCount,
|
||||
fileCount: fileCount,
|
||||
tasks: taskCounts,
|
||||
streams: streams.map(s => s.name),
|
||||
tables: tables.map((t: any) => t.name),
|
||||
collections: [],
|
||||
yourName: peerConn?.groups?.[0]?.name ?? "unknown",
|
||||
yourGroups: peerConn?.groups ?? [],
|
||||
});
|
||||
log.info("ws mesh_info", { presence_id: presenceId });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
metrics.messagesRejectedTotal.inc({ reason: "parse_or_handler" });
|
||||
@@ -1081,6 +1691,11 @@ function handleConnection(ws: WebSocket): void {
|
||||
connections.delete(presenceId);
|
||||
if (conn) decMeshCount(conn.meshId);
|
||||
await disconnectPresence(presenceId);
|
||||
// Clean up stream subscriptions for this peer
|
||||
for (const [key, subs] of streamSubscriptions) {
|
||||
subs.delete(presenceId);
|
||||
if (subs.size === 0) streamSubscriptions.delete(key);
|
||||
}
|
||||
log.info("ws close", { presence_id: presenceId });
|
||||
}
|
||||
});
|
||||
|
||||
22
apps/broker/src/neo4j-client.ts
Normal file
22
apps/broker/src/neo4j-client.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import neo4j from "neo4j-driver";
|
||||
import { env } from "./env";
|
||||
|
||||
export const neo4jDriver = neo4j.driver(
|
||||
env.NEO4J_URL,
|
||||
neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD),
|
||||
);
|
||||
|
||||
export function meshDbName(meshId: string): string {
|
||||
return `mesh_${meshId.toLowerCase().replace(/[^a-z0-9]/g, "")}`;
|
||||
}
|
||||
|
||||
export async function ensureDatabase(name: string): Promise<void> {
|
||||
const session = neo4jDriver.session({ database: "system" });
|
||||
try {
|
||||
await session.run(`CREATE DATABASE $name IF NOT EXISTS`, { name });
|
||||
} catch {
|
||||
/* may not support multi-db in community edition — fall back to default */
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
24
apps/broker/src/qdrant.ts
Normal file
24
apps/broker/src/qdrant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import { env } from "./env";
|
||||
|
||||
export const qdrant = new QdrantClient({ url: env.QDRANT_URL });
|
||||
|
||||
export function meshCollectionName(
|
||||
meshId: string,
|
||||
collection: string,
|
||||
): string {
|
||||
return `mesh_${meshId}_${collection}`.toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
||||
}
|
||||
|
||||
export async function ensureCollection(
|
||||
name: string,
|
||||
vectorSize = 1536,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await qdrant.getCollection(name);
|
||||
} catch {
|
||||
await qdrant.createCollection(name, {
|
||||
vectors: { size: vectorSize, distance: "Cosine" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,133 @@ export interface WSMemoryResultsMessage {
|
||||
}>;
|
||||
}
|
||||
|
||||
// --- Vector storage messages ---
|
||||
|
||||
/** Client → broker: store a text document in a vector collection. */
|
||||
export interface WSVectorStoreMessage {
|
||||
type: "vector_store";
|
||||
collection: string;
|
||||
text: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Client → broker: search a vector collection. */
|
||||
export interface WSVectorSearchMessage {
|
||||
type: "vector_search";
|
||||
collection: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Client → broker: delete a point from a vector collection. */
|
||||
export interface WSVectorDeleteMessage {
|
||||
type: "vector_delete";
|
||||
collection: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list all vector collections for this mesh. */
|
||||
export interface WSListCollectionsMessage {
|
||||
type: "list_collections";
|
||||
}
|
||||
|
||||
// --- Graph database messages ---
|
||||
|
||||
/** Client → broker: run a read-only Cypher query. */
|
||||
export interface WSGraphQueryMessage {
|
||||
type: "graph_query";
|
||||
cypher: string;
|
||||
}
|
||||
|
||||
/** Client → broker: run a write Cypher statement. */
|
||||
export interface WSGraphExecuteMessage {
|
||||
type: "graph_execute";
|
||||
cypher: string;
|
||||
}
|
||||
|
||||
// --- Mesh database (per-mesh PostgreSQL schema) messages ---
|
||||
|
||||
/** Client → broker: run a SELECT query in the mesh's schema. */
|
||||
export interface WSMeshQueryMessage {
|
||||
type: "mesh_query";
|
||||
sql: string;
|
||||
}
|
||||
|
||||
/** Client → broker: run a DDL/DML statement in the mesh's schema. */
|
||||
export interface WSMeshExecuteMessage {
|
||||
type: "mesh_execute";
|
||||
sql: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list tables and columns in the mesh's schema. */
|
||||
export interface WSMeshSchemaMessage {
|
||||
type: "mesh_schema";
|
||||
}
|
||||
|
||||
// --- Vector/Graph response messages ---
|
||||
|
||||
/** Broker → client: vector search results. */
|
||||
export interface WSVectorResultsMessage {
|
||||
type: "vector_results";
|
||||
results: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
score: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Broker → client: list of vector collections. */
|
||||
export interface WSCollectionListMessage {
|
||||
type: "collection_list";
|
||||
collections: string[];
|
||||
}
|
||||
|
||||
/** Broker → client: graph query results. */
|
||||
export interface WSGraphResultMessage {
|
||||
type: "graph_result";
|
||||
records: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Broker → client: mesh SQL query results. */
|
||||
export interface WSMeshQueryResultMessage {
|
||||
type: "mesh_query_result";
|
||||
columns: string[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
/** Broker → client: mesh schema introspection results. */
|
||||
export interface WSMeshSchemaResultMessage {
|
||||
type: "mesh_schema_result";
|
||||
tables: Array<{
|
||||
name: string;
|
||||
columns: Array<{ name: string; type: string; nullable: boolean }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Client → broker: get full mesh overview. */
|
||||
export interface WSMeshInfoMessage {
|
||||
type: "mesh_info";
|
||||
}
|
||||
|
||||
/** Broker → client: aggregated mesh overview. */
|
||||
export interface WSMeshInfoResultMessage {
|
||||
type: "mesh_info_result";
|
||||
mesh: string;
|
||||
peers: number;
|
||||
groups: string[];
|
||||
stateKeys: string[];
|
||||
memoryCount: number;
|
||||
fileCount: number;
|
||||
tasks: { open: number; claimed: number; done: number };
|
||||
streams: string[];
|
||||
tables: string[];
|
||||
collections: string[];
|
||||
yourName: string;
|
||||
yourGroups: Array<{ name: string; role?: string }>;
|
||||
}
|
||||
|
||||
/** Client → broker: check delivery status of a message. */
|
||||
export interface WSMessageStatusMessage {
|
||||
type: "message_status";
|
||||
@@ -309,6 +436,170 @@ export interface WSFileStatusResultMessage {
|
||||
}>;
|
||||
}
|
||||
|
||||
// --- Context sharing messages ---
|
||||
|
||||
/** Client → broker: share current working context. */
|
||||
export interface WSShareContextMessage {
|
||||
type: "share_context";
|
||||
summary: string;
|
||||
filesRead?: string[];
|
||||
keyFindings?: string[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** Client → broker: search contexts by query. */
|
||||
export interface WSGetContextMessage {
|
||||
type: "get_context";
|
||||
query: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list all contexts in the mesh. */
|
||||
export interface WSListContextsMessage {
|
||||
type: "list_contexts";
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for share_context. */
|
||||
export interface WSContextSharedMessage {
|
||||
type: "context_shared";
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to get_context. */
|
||||
export interface WSContextResultsMessage {
|
||||
type: "context_results";
|
||||
contexts: Array<{
|
||||
peerName: string;
|
||||
summary: string;
|
||||
filesRead: string[];
|
||||
keyFindings: string[];
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_contexts. */
|
||||
export interface WSContextListMessage {
|
||||
type: "context_list";
|
||||
contexts: Array<{
|
||||
peerName: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// --- Task messages ---
|
||||
|
||||
/** Client → broker: create a task. */
|
||||
export interface WSCreateTaskMessage {
|
||||
type: "create_task";
|
||||
title: string;
|
||||
assignee?: string;
|
||||
priority?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** Client → broker: claim an open task. */
|
||||
export interface WSClaimTaskMessage {
|
||||
type: "claim_task";
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
/** Client → broker: mark a task as done. */
|
||||
export interface WSCompleteTaskMessage {
|
||||
type: "complete_task";
|
||||
taskId: string;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list tasks with optional filters. */
|
||||
export interface WSListTasksMessage {
|
||||
type: "list_tasks";
|
||||
status?: string;
|
||||
assignee?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for create_task. */
|
||||
export interface WSTaskCreatedMessage {
|
||||
type: "task_created";
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_tasks, claim_task, complete_task. */
|
||||
export interface WSTaskListMessage {
|
||||
type: "task_list";
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
assignee: string | null;
|
||||
claimedBy: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
createdBy: string | null;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// --- Stream messages ---
|
||||
|
||||
/** Client → broker: create a named real-time stream. */
|
||||
export interface WSCreateStreamMessage {
|
||||
type: "create_stream";
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Client → broker: publish data to a stream. */
|
||||
export interface WSPublishMessage {
|
||||
type: "publish";
|
||||
stream: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
/** Client → broker: subscribe to a stream. */
|
||||
export interface WSSubscribeMessage {
|
||||
type: "subscribe";
|
||||
stream: string;
|
||||
}
|
||||
|
||||
/** Client → broker: unsubscribe from a stream. */
|
||||
export interface WSUnsubscribeMessage {
|
||||
type: "unsubscribe";
|
||||
stream: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list all streams in the mesh. */
|
||||
export interface WSListStreamsMessage {
|
||||
type: "list_streams";
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for create_stream. */
|
||||
export interface WSStreamCreatedMessage {
|
||||
type: "stream_created";
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Broker → client: real-time data pushed from a stream. */
|
||||
export interface WSStreamDataMessage {
|
||||
type: "stream_data";
|
||||
stream: string;
|
||||
data: unknown;
|
||||
publishedBy: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_streams. */
|
||||
export interface WSStreamListMessage {
|
||||
type: "stream_list";
|
||||
streams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
subscriberCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Broker → client: structured error. */
|
||||
export interface WSErrorMessage {
|
||||
type: "error";
|
||||
@@ -335,7 +626,29 @@ export type WSClientMessage =
|
||||
| WSGetFileMessage
|
||||
| WSListFilesMessage
|
||||
| WSFileStatusMessage
|
||||
| WSDeleteFileMessage;
|
||||
| WSDeleteFileMessage
|
||||
| WSShareContextMessage
|
||||
| WSGetContextMessage
|
||||
| WSListContextsMessage
|
||||
| WSCreateTaskMessage
|
||||
| WSClaimTaskMessage
|
||||
| WSCompleteTaskMessage
|
||||
| WSListTasksMessage
|
||||
| WSVectorStoreMessage
|
||||
| WSVectorSearchMessage
|
||||
| WSVectorDeleteMessage
|
||||
| WSListCollectionsMessage
|
||||
| WSGraphQueryMessage
|
||||
| WSGraphExecuteMessage
|
||||
| WSMeshQueryMessage
|
||||
| WSMeshExecuteMessage
|
||||
| WSMeshSchemaMessage
|
||||
| WSCreateStreamMessage
|
||||
| WSPublishMessage
|
||||
| WSSubscribeMessage
|
||||
| WSUnsubscribeMessage
|
||||
| WSListStreamsMessage
|
||||
| WSMeshInfoMessage;
|
||||
|
||||
export type WSServerMessage =
|
||||
| WSHelloAckMessage
|
||||
@@ -351,4 +664,18 @@ export type WSServerMessage =
|
||||
| WSFileUrlMessage
|
||||
| WSFileListMessage
|
||||
| WSFileStatusResultMessage
|
||||
| WSContextSharedMessage
|
||||
| WSContextResultsMessage
|
||||
| WSContextListMessage
|
||||
| WSTaskCreatedMessage
|
||||
| WSTaskListMessage
|
||||
| WSVectorResultsMessage
|
||||
| WSCollectionListMessage
|
||||
| WSGraphResultMessage
|
||||
| WSMeshQueryResultMessage
|
||||
| WSMeshSchemaResultMessage
|
||||
| WSStreamCreatedMessage
|
||||
| WSStreamDataMessage
|
||||
| WSStreamListMessage
|
||||
| WSMeshInfoResultMessage
|
||||
| WSErrorMessage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
|
||||
@@ -166,6 +166,26 @@ When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATEL
|
||||
| list_files(query?, from?) | Find files shared in the mesh. |
|
||||
| file_status(id) | Check who has accessed a file. |
|
||||
| delete_file(id) | Remove a shared file from the mesh. |
|
||||
| vector_store(collection, text, metadata?) | Store embedding in per-mesh Qdrant collection. |
|
||||
| vector_search(collection, query, limit?) | Semantic search over stored embeddings. |
|
||||
| vector_delete(collection, id) | Remove an embedding. |
|
||||
| list_collections() | List vector collections in this mesh. |
|
||||
| graph_query(cypher) | Read-only Cypher query on per-mesh Neo4j. |
|
||||
| graph_execute(cypher) | Write Cypher query (CREATE, MERGE, DELETE). |
|
||||
| mesh_query(sql) | Run a SELECT query on the per-mesh shared database. |
|
||||
| mesh_execute(sql) | Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE). |
|
||||
| mesh_schema() | List tables and columns in the per-mesh shared database. |
|
||||
| create_stream(name) | Create a real-time data stream in the mesh. |
|
||||
| publish(stream, data) | Push data to a stream. Subscribers receive it in real-time. |
|
||||
| subscribe(stream) | Subscribe to a stream. Data pushes arrive as channel notifications. |
|
||||
| list_streams() | List active streams in the mesh. |
|
||||
| share_context(summary, files_read?, key_findings?, tags?) | Share session understanding with peers. |
|
||||
| get_context(query) | Find context from peers who explored an area. |
|
||||
| list_contexts() | See what all peers currently know. |
|
||||
| create_task(title, assignee?, priority?, tags?) | Create a work item. |
|
||||
| claim_task(id) | Claim an unclaimed task. |
|
||||
| complete_task(id, result?) | Mark task done with optional result. |
|
||||
| list_tasks(status?, assignee?) | List tasks filtered by status/assignee. |
|
||||
|
||||
If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`).
|
||||
|
||||
@@ -192,6 +212,24 @@ Persistent knowledge that survives across sessions. Use remember(content, tags?)
|
||||
share_file for persistent references, send_message(file:) for ephemeral attachments.
|
||||
Tags on shared files make them searchable. Use list_files to find what peers shared.
|
||||
|
||||
## Vectors
|
||||
Store and search semantic embeddings. Use vector_store to index content, vector_search to find similar content.
|
||||
|
||||
## Graph
|
||||
Build and query entity relationship graphs. Use graph_execute for writes (CREATE, MERGE), graph_query for reads (MATCH).
|
||||
|
||||
## Mesh Database
|
||||
Per-mesh PostgreSQL database. Use mesh_execute for DDL/DML (CREATE TABLE, INSERT), mesh_query for SELECT, mesh_schema to inspect tables. Schema auto-created on first use.
|
||||
|
||||
## Streams
|
||||
Real-time data channels. create_stream to start one, publish to push data, subscribe to receive pushes. Use for build logs, deploy status, live metrics.
|
||||
|
||||
## Context
|
||||
Share your session understanding with peers. Use share_context after exploring a codebase area. Check get_context before re-reading files another peer already analyzed.
|
||||
|
||||
## Tasks
|
||||
Create and claim work items. create_task to propose work, claim_task to take ownership, complete_task when done. Prevents duplicate effort.
|
||||
|
||||
## Priority
|
||||
- "now": interrupt immediately, even if recipient is in DND (use for urgent: broken deploy, blocking issue)
|
||||
- "next" (default): deliver when recipient goes idle (normal coordination)
|
||||
@@ -455,6 +493,213 @@ Call list_peers at session start to understand who is online, their roles, and w
|
||||
return text(`Deleted: ${id}`);
|
||||
}
|
||||
|
||||
// --- Vectors ---
|
||||
case "vector_store": {
|
||||
const { collection, text: storeText, metadata } = (args ?? {}) as { collection?: string; text?: string; metadata?: Record<string, unknown> };
|
||||
if (!collection || !storeText) return text("vector_store: `collection` and `text` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("vector_store: not connected", true);
|
||||
const id = await client.vectorStore(collection, storeText, metadata);
|
||||
return text(`Stored in ${collection}${id ? ` (${id})` : ""}`);
|
||||
}
|
||||
case "vector_search": {
|
||||
const { collection, query, limit } = (args ?? {}) as { collection?: string; query?: string; limit?: number };
|
||||
if (!collection || !query) return text("vector_search: `collection` and `query` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("vector_search: not connected", true);
|
||||
const results = await client.vectorSearch(collection, query, limit);
|
||||
if (results.length === 0) return text(`No results in ${collection} for "${query}".`);
|
||||
const lines = results.map(r => `- [${r.id.slice(0, 8)}…] (score: ${r.score.toFixed(3)}) ${r.text.slice(0, 120)}${r.text.length > 120 ? "…" : ""}`);
|
||||
return text(`${results.length} result(s) in ${collection}:\n${lines.join("\n")}`);
|
||||
}
|
||||
case "vector_delete": {
|
||||
const { collection, id } = (args ?? {}) as { collection?: string; id?: string };
|
||||
if (!collection || !id) return text("vector_delete: `collection` and `id` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("vector_delete: not connected", true);
|
||||
await client.vectorDelete(collection, id);
|
||||
return text(`Deleted ${id} from ${collection}`);
|
||||
}
|
||||
case "list_collections": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("list_collections: not connected", true);
|
||||
const collections = await client.listCollections();
|
||||
if (collections.length === 0) return text("No vector collections.");
|
||||
return text(`Collections:\n${collections.map(c => `- ${c}`).join("\n")}`);
|
||||
}
|
||||
|
||||
// --- Graph ---
|
||||
case "graph_query": {
|
||||
const { cypher } = (args ?? {}) as { cypher?: string };
|
||||
if (!cypher) return text("graph_query: `cypher` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("graph_query: not connected", true);
|
||||
const rows = await client.graphQuery(cypher);
|
||||
if (rows.length === 0) return text("No results.");
|
||||
return text(JSON.stringify(rows, null, 2));
|
||||
}
|
||||
case "graph_execute": {
|
||||
const { cypher } = (args ?? {}) as { cypher?: string };
|
||||
if (!cypher) return text("graph_execute: `cypher` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("graph_execute: not connected", true);
|
||||
const rows = await client.graphExecute(cypher);
|
||||
return text(rows.length > 0 ? JSON.stringify(rows, null, 2) : "Executed successfully.");
|
||||
}
|
||||
|
||||
// --- Context ---
|
||||
case "share_context": {
|
||||
const { summary, files_read, key_findings, tags } = (args ?? {}) as { summary?: string; files_read?: string[]; key_findings?: string[]; tags?: string[] };
|
||||
if (!summary) return text("share_context: `summary` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("share_context: not connected", true);
|
||||
await client.shareContext(summary, files_read, key_findings, tags);
|
||||
return text(`Context shared: "${summary.slice(0, 80)}${summary.length > 80 ? "…" : ""}"`);
|
||||
}
|
||||
case "get_context": {
|
||||
const { query } = (args ?? {}) as { query?: string };
|
||||
if (!query) return text("get_context: `query` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("get_context: not connected", true);
|
||||
const contexts = await client.getContext(query);
|
||||
if (contexts.length === 0) return text(`No context found for "${query}".`);
|
||||
const lines = contexts.map(c => {
|
||||
const files = c.filesRead.length ? `\n Files: ${c.filesRead.join(", ")}` : "";
|
||||
const findings = c.keyFindings.length ? `\n Findings: ${c.keyFindings.join("; ")}` : "";
|
||||
return `- **${c.peerName}** (${c.updatedAt}): ${c.summary}${files}${findings}`;
|
||||
});
|
||||
return text(`${contexts.length} context(s):\n${lines.join("\n")}`);
|
||||
}
|
||||
case "list_contexts": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("list_contexts: not connected", true);
|
||||
const contexts = await client.listContexts();
|
||||
if (contexts.length === 0) return text("No peer contexts shared yet.");
|
||||
const lines = contexts.map(c => `- **${c.peerName}**: ${c.summary}${c.tags.length ? ` [${c.tags.join(", ")}]` : ""}`);
|
||||
return text(`Peer contexts:\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
// --- Tasks ---
|
||||
case "create_task": {
|
||||
const { title, assignee, priority, tags } = (args ?? {}) as { title?: string; assignee?: string; priority?: string; tags?: string[] };
|
||||
if (!title) return text("create_task: `title` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("create_task: not connected", true);
|
||||
const id = await client.createTask(title, assignee, priority, tags);
|
||||
return text(`Task created${id ? ` (${id})` : ""}: "${title}"${assignee ? ` → ${assignee}` : ""}`);
|
||||
}
|
||||
case "claim_task": {
|
||||
const { id } = (args ?? {}) as { id?: string };
|
||||
if (!id) return text("claim_task: `id` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("claim_task: not connected", true);
|
||||
await client.claimTask(id);
|
||||
return text(`Claimed task: ${id}`);
|
||||
}
|
||||
case "complete_task": {
|
||||
const { id, result } = (args ?? {}) as { id?: string; result?: string };
|
||||
if (!id) return text("complete_task: `id` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("complete_task: not connected", true);
|
||||
await client.completeTask(id, result);
|
||||
return text(`Completed task: ${id}${result ? ` — ${result}` : ""}`);
|
||||
}
|
||||
case "list_tasks": {
|
||||
const { status, assignee } = (args ?? {}) as { status?: string; assignee?: string };
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("list_tasks: not connected", true);
|
||||
const tasks = await client.listTasks(status, assignee);
|
||||
if (tasks.length === 0) return text("No tasks found.");
|
||||
const lines = tasks.map(t => `- [${t.id.slice(0, 8)}…] **${t.title}** (${t.status}, ${t.priority}) ${t.assignee ? `→ ${t.assignee}` : "unassigned"} (by ${t.createdBy})`);
|
||||
return text(`${tasks.length} task(s):\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
// --- Mesh Database ---
|
||||
case "mesh_query": {
|
||||
const { sql: querySql } = (args ?? {}) as { sql?: string };
|
||||
if (!querySql) return text("mesh_query: `sql` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_query: not connected", true);
|
||||
const result = await client.meshQuery(querySql);
|
||||
if (!result) return text("mesh_query: query failed or timed out", true);
|
||||
if (result.rows.length === 0) return text(`Query returned 0 rows.`);
|
||||
const header = `| ${result.columns.join(" | ")} |`;
|
||||
const sep = `| ${result.columns.map(() => "---").join(" | ")} |`;
|
||||
const rows = result.rows.map(r => `| ${result.columns.map(c => String(r[c] ?? "")).join(" | ")} |`);
|
||||
return text(`${result.rowCount} row(s):\n${header}\n${sep}\n${rows.join("\n")}`);
|
||||
}
|
||||
case "mesh_execute": {
|
||||
const { sql: execSql } = (args ?? {}) as { sql?: string };
|
||||
if (!execSql) return text("mesh_execute: `sql` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_execute: not connected", true);
|
||||
await client.meshExecute(execSql);
|
||||
return text(`Executed.`);
|
||||
}
|
||||
case "mesh_schema": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_schema: not connected", true);
|
||||
const tables = await client.meshSchema();
|
||||
if (!tables || tables.length === 0) return text("No tables in mesh database.");
|
||||
const lines = tables.map(t => `**${t.name}**: ${t.columns.map(c => `${c.name} (${c.type}${c.nullable ? ", nullable" : ""})`).join(", ")}`);
|
||||
return text(lines.join("\n"));
|
||||
}
|
||||
|
||||
// --- Streams ---
|
||||
case "create_stream": {
|
||||
const { name: streamName } = (args ?? {}) as { name?: string };
|
||||
if (!streamName) return text("create_stream: `name` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("create_stream: not connected", true);
|
||||
const streamId = await client.createStream(streamName);
|
||||
return text(`Stream created: ${streamName}${streamId ? ` (${streamId})` : ""}`);
|
||||
}
|
||||
case "publish": {
|
||||
const { stream: pubStream, data: pubData } = (args ?? {}) as { stream?: string; data?: unknown };
|
||||
if (!pubStream) return text("publish: `stream` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("publish: not connected", true);
|
||||
await client.publish(pubStream, pubData);
|
||||
return text(`Published to ${pubStream}.`);
|
||||
}
|
||||
case "subscribe": {
|
||||
const { stream: subStream } = (args ?? {}) as { stream?: string };
|
||||
if (!subStream) return text("subscribe: `stream` required", true);
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("subscribe: not connected", true);
|
||||
await client.subscribe(subStream);
|
||||
return text(`Subscribed to ${subStream}. Data pushes will arrive as channel notifications.`);
|
||||
}
|
||||
case "list_streams": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("list_streams: not connected", true);
|
||||
const streams = await client.listStreams();
|
||||
if (streams.length === 0) return text("No active streams.");
|
||||
const lines = streams.map(s => `- **${s.name}** (${s.id.slice(0, 8)}…) by ${s.createdBy}, ${s.subscriberCount} subscriber(s)`);
|
||||
return text(lines.join("\n"));
|
||||
}
|
||||
|
||||
case "mesh_info": {
|
||||
const client = allClients()[0];
|
||||
if (!client) return text("mesh_info: not connected", true);
|
||||
const info = await client.meshInfo();
|
||||
if (!info) return text("mesh_info: timed out", true);
|
||||
const lines = [
|
||||
`**Mesh**: ${info.mesh}`,
|
||||
`**Peers**: ${info.peers}`,
|
||||
`**Groups**: ${(info.groups as string[])?.join(", ") || "none"}`,
|
||||
`**State keys**: ${(info.stateKeys as string[])?.join(", ") || "none"}`,
|
||||
`**Memories**: ${info.memoryCount}`,
|
||||
`**Files**: ${info.fileCount}`,
|
||||
`**Tasks**: open=${(info.tasks as any)?.open ?? 0}, claimed=${(info.tasks as any)?.claimed ?? 0}, done=${(info.tasks as any)?.done ?? 0}`,
|
||||
`**Streams**: ${(info.streams as string[])?.join(", ") || "none"}`,
|
||||
`**Tables**: ${(info.tables as string[])?.join(", ") || "none"}`,
|
||||
`**Your name**: ${info.yourName}`,
|
||||
`**Your groups**: ${(info.yourGroups as any[])?.map((g: any) => `@${g.name}${g.role ? ':' + g.role : ''}`).join(", ") || "none"}`,
|
||||
];
|
||||
return text(lines.join("\n"));
|
||||
}
|
||||
|
||||
default:
|
||||
return text(`Unknown tool: ${name}`, true);
|
||||
}
|
||||
@@ -499,6 +744,22 @@ Call list_peers at session start to understand who is online, their roles, and w
|
||||
}
|
||||
});
|
||||
|
||||
client.onStreamData(async (evt) => {
|
||||
try {
|
||||
await server.notification({
|
||||
method: "notifications/claude/channel",
|
||||
params: {
|
||||
content: `[stream:${evt.stream}] from ${evt.publishedBy}: ${JSON.stringify(evt.data)}`,
|
||||
meta: {
|
||||
kind: "stream_data",
|
||||
stream: evt.stream,
|
||||
published_by: evt.publishedBy,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
});
|
||||
|
||||
client.onStateChange(async (change) => {
|
||||
try {
|
||||
await server.notification({
|
||||
|
||||
@@ -269,4 +269,290 @@ export const TOOLS: Tool[] = [
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Vector tools ---
|
||||
{
|
||||
name: "vector_store",
|
||||
description:
|
||||
"Store an embedding in a per-mesh Qdrant collection. Auto-creates the collection on first use.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collection: { type: "string", description: "Collection name" },
|
||||
text: { type: "string", description: "Text to embed and store" },
|
||||
metadata: {
|
||||
type: "object",
|
||||
description: "Optional metadata to attach",
|
||||
},
|
||||
},
|
||||
required: ["collection", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vector_search",
|
||||
description: "Semantic search over stored embeddings in a collection.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collection: { type: "string", description: "Collection name" },
|
||||
query: { type: "string", description: "Search query text" },
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Max results (default: 10)",
|
||||
},
|
||||
},
|
||||
required: ["collection", "query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vector_delete",
|
||||
description: "Remove an embedding from a collection.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collection: { type: "string", description: "Collection name" },
|
||||
id: { type: "string", description: "Embedding ID to delete" },
|
||||
},
|
||||
required: ["collection", "id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_collections",
|
||||
description: "List vector collections in this mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Graph tools ---
|
||||
{
|
||||
name: "graph_query",
|
||||
description:
|
||||
"Run a read-only Cypher query on the per-mesh Neo4j database.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cypher: { type: "string", description: "Cypher MATCH query" },
|
||||
},
|
||||
required: ["cypher"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "graph_execute",
|
||||
description:
|
||||
"Run a write Cypher query (CREATE, MERGE, DELETE) on the per-mesh Neo4j database.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cypher: { type: "string", description: "Cypher write query" },
|
||||
},
|
||||
required: ["cypher"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Mesh Database tools ---
|
||||
{
|
||||
name: "mesh_query",
|
||||
description:
|
||||
"Run a SELECT query on the per-mesh shared database.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sql: { type: "string", description: "SQL SELECT query" },
|
||||
},
|
||||
required: ["sql"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_execute",
|
||||
description:
|
||||
"Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sql: { type: "string", description: "SQL statement" },
|
||||
},
|
||||
required: ["sql"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_schema",
|
||||
description:
|
||||
"List tables and columns in the per-mesh shared database.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Stream tools ---
|
||||
{
|
||||
name: "create_stream",
|
||||
description:
|
||||
"Create a real-time data stream in the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Stream name" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "publish",
|
||||
description:
|
||||
"Push data to a stream. Subscribers receive it in real-time.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stream: { type: "string", description: "Stream name" },
|
||||
data: { description: "Any JSON data to publish" },
|
||||
},
|
||||
required: ["stream", "data"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subscribe",
|
||||
description:
|
||||
"Subscribe to a stream. Data pushes arrive as channel notifications.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stream: { type: "string", description: "Stream name" },
|
||||
},
|
||||
required: ["stream"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_streams",
|
||||
description:
|
||||
"List active streams in the mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Context tools ---
|
||||
{
|
||||
name: "share_context",
|
||||
description:
|
||||
"Share your session understanding with the mesh. Call after exploring a codebase area.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
summary: {
|
||||
type: "string",
|
||||
description: "Summary of what you explored/learned",
|
||||
},
|
||||
files_read: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "File paths you read",
|
||||
},
|
||||
key_findings: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Key findings or insights",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for categorization",
|
||||
},
|
||||
},
|
||||
required: ["summary"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_context",
|
||||
description:
|
||||
"Find context from peers who explored an area. Check before re-reading files another peer already analyzed.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Search query (file path, topic, etc.)",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_contexts",
|
||||
description: "See what all peers currently know about the codebase.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Task tools ---
|
||||
{
|
||||
name: "create_task",
|
||||
description: "Create a work item for the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Task title" },
|
||||
assignee: {
|
||||
type: "string",
|
||||
description: "Peer name to assign (optional)",
|
||||
},
|
||||
priority: {
|
||||
type: "string",
|
||||
enum: ["low", "normal", "high", "urgent"],
|
||||
description: "Priority level (default: normal)",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for categorization",
|
||||
},
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claim_task",
|
||||
description: "Claim an unclaimed task to take ownership.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Task ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complete_task",
|
||||
description: "Mark a task as done with an optional result summary.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Task ID" },
|
||||
result: {
|
||||
type: "string",
|
||||
description: "Summary of what was done",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_tasks",
|
||||
description: "List tasks filtered by status and/or assignee.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["open", "claimed", "completed"],
|
||||
description: "Filter by status",
|
||||
},
|
||||
assignee: {
|
||||
type: "string",
|
||||
description: "Filter by assignee name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// --- Mesh info ---
|
||||
{
|
||||
name: "mesh_info",
|
||||
description:
|
||||
"Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -415,6 +415,19 @@ export class BrokerClient {
|
||||
private fileUrlResolvers: Array<(result: { url: string; name: string } | null) => void> = [];
|
||||
private fileListResolvers: Array<(files: Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) => void> = [];
|
||||
private fileStatusResolvers: Array<(accesses: Array<{ peerName: string; accessedAt: string }>) => void> = [];
|
||||
private vectorStoredResolvers: Array<(id: string | null) => void> = [];
|
||||
private vectorResultsResolvers: Array<(results: Array<{ id: string; text: string; score: number; metadata?: Record<string, unknown> }>) => void> = [];
|
||||
private collectionListResolvers: Array<(collections: string[]) => void> = [];
|
||||
private graphResultResolvers: Array<(rows: Array<Record<string, unknown>>) => void> = [];
|
||||
private contextListResolvers: Array<(contexts: Array<{ peerName: string; summary: string; tags: string[]; updatedAt: string }>) => void> = [];
|
||||
private contextResultsResolvers: Array<(contexts: Array<{ peerName: string; summary: string; filesRead: string[]; keyFindings: string[]; tags: string[]; updatedAt: string }>) => void> = [];
|
||||
private taskCreatedResolvers: Array<(id: string | null) => void> = [];
|
||||
private taskListResolvers: Array<(tasks: Array<{ id: string; title: string; assignee: string; status: string; priority: string; createdBy: string }>) => void> = [];
|
||||
private meshQueryResolvers: Array<(result: { columns: string[]; rows: Array<Record<string, unknown>>; rowCount: number } | null) => void> = [];
|
||||
private meshSchemaResolvers: Array<(tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable: boolean }> }>) => void> = [];
|
||||
private streamCreatedResolvers: Array<(id: string | null) => void> = [];
|
||||
private streamListResolvers: Array<(streams: Array<{ id: string; name: string; createdBy: string; subscriberCount: number }>) => void> = [];
|
||||
private streamDataHandlers = new Set<(data: { stream: string; data: unknown; publishedBy: string }) => void>();
|
||||
|
||||
async messageStatus(messageId: string): Promise<{ messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||
@@ -517,12 +530,262 @@ export class BrokerClient {
|
||||
return body.fileId ?? null;
|
||||
}
|
||||
|
||||
// --- Vectors ---
|
||||
|
||||
/** Store an embedding in a per-mesh Qdrant collection. */
|
||||
async vectorStore(collection: string, text: string, metadata?: Record<string, unknown>): Promise<string | null> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||
return new Promise((resolve) => {
|
||||
this.vectorStoredResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "vector_store", collection, text, metadata }));
|
||||
setTimeout(() => {
|
||||
const idx = this.vectorStoredResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.vectorStoredResolvers.splice(idx, 1); resolve(null); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Semantic search over stored embeddings. */
|
||||
async vectorSearch(collection: string, query: string, limit?: number): Promise<Array<{ id: string; text: string; score: number; metadata?: Record<string, unknown> }>> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
this.vectorResultsResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "vector_search", collection, query, limit }));
|
||||
setTimeout(() => {
|
||||
const idx = this.vectorResultsResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.vectorResultsResolvers.splice(idx, 1); resolve([]); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an embedding from a collection. */
|
||||
async vectorDelete(collection: string, id: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "vector_delete", collection, id }));
|
||||
}
|
||||
|
||||
/** List vector collections in this mesh. */
|
||||
async listCollections(): Promise<string[]> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
this.collectionListResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "list_collections" }));
|
||||
setTimeout(() => {
|
||||
const idx = this.collectionListResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.collectionListResolvers.splice(idx, 1); resolve([]); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Graph ---
|
||||
|
||||
/** Run a read query on the per-mesh Neo4j database. */
|
||||
async graphQuery(cypher: string): Promise<Array<Record<string, unknown>>> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
this.graphResultResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "graph_query", cypher }));
|
||||
setTimeout(() => {
|
||||
const idx = this.graphResultResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.graphResultResolvers.splice(idx, 1); resolve([]); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Run a write query (CREATE, MERGE, DELETE) on the per-mesh Neo4j database. */
|
||||
async graphExecute(cypher: string): Promise<Array<Record<string, unknown>>> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
this.graphResultResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "graph_execute", cypher }));
|
||||
setTimeout(() => {
|
||||
const idx = this.graphResultResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.graphResultResolvers.splice(idx, 1); resolve([]); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Context ---
|
||||
|
||||
/** Share session understanding with the mesh. */
|
||||
async shareContext(summary: string, filesRead?: string[], keyFindings?: string[], tags?: string[]): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "share_context", summary, filesRead, keyFindings, tags }));
|
||||
}
|
||||
|
||||
/** Find context from peers who explored an area. */
|
||||
async getContext(query: string): Promise<Array<{ peerName: string; summary: string; filesRead: string[]; keyFindings: string[]; tags: string[]; updatedAt: string }>> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
this.contextResultsResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "get_context", query }));
|
||||
setTimeout(() => {
|
||||
const idx = this.contextResultsResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.contextResultsResolvers.splice(idx, 1); resolve([]); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** See what all peers currently know. */
|
||||
async listContexts(): Promise<Array<{ peerName: string; summary: string; tags: string[]; updatedAt: string }>> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
this.contextListResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "list_contexts" }));
|
||||
setTimeout(() => {
|
||||
const idx = this.contextListResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.contextListResolvers.splice(idx, 1); resolve([]); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
/** Create a work item. */
|
||||
async createTask(title: string, assignee?: string, priority?: string, tags?: string[]): Promise<string | null> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||
return new Promise((resolve) => {
|
||||
this.taskCreatedResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "create_task", title, assignee, priority, tags }));
|
||||
setTimeout(() => {
|
||||
const idx = this.taskCreatedResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.taskCreatedResolvers.splice(idx, 1); resolve(null); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Claim an unclaimed task. */
|
||||
async claimTask(id: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "claim_task", id }));
|
||||
}
|
||||
|
||||
/** Mark a task done with optional result. */
|
||||
async completeTask(id: string, result?: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "complete_task", id, result }));
|
||||
}
|
||||
|
||||
/** List tasks filtered by status/assignee. */
|
||||
async listTasks(status?: string, assignee?: string): Promise<Array<{ id: string; title: string; assignee: string; status: string; priority: string; createdBy: string }>> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
this.taskListResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "list_tasks", status, assignee }));
|
||||
setTimeout(() => {
|
||||
const idx = this.taskListResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.taskListResolvers.splice(idx, 1); resolve([]); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mesh Database ---
|
||||
|
||||
/** Run a SELECT query on the per-mesh shared database. */
|
||||
async meshQuery(sql: string): Promise<{ columns: string[]; rows: Array<Record<string, unknown>>; rowCount: number } | null> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||
return new Promise((resolve) => {
|
||||
this.meshQueryResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "mesh_query", sql }));
|
||||
setTimeout(() => {
|
||||
const idx = this.meshQueryResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.meshQueryResolvers.splice(idx, 1); resolve(null); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE). */
|
||||
async meshExecute(sql: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "mesh_execute", sql }));
|
||||
}
|
||||
|
||||
/** List tables and columns in the per-mesh shared database. */
|
||||
async meshSchema(): Promise<Array<{ name: string; columns: Array<{ name: string; type: string; nullable: boolean }> }>> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
this.meshSchemaResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "mesh_schema" }));
|
||||
setTimeout(() => {
|
||||
const idx = this.meshSchemaResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.meshSchemaResolvers.splice(idx, 1); resolve([]); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Streams ---
|
||||
|
||||
/** Create a real-time data stream in the mesh. */
|
||||
async createStream(name: string): Promise<string | null> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||
return new Promise((resolve) => {
|
||||
this.streamCreatedResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "create_stream", name }));
|
||||
setTimeout(() => {
|
||||
const idx = this.streamCreatedResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.streamCreatedResolvers.splice(idx, 1); resolve(null); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Push data to a stream. Subscribers receive it in real-time. */
|
||||
async publish(stream: string, data: unknown): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "publish", stream, data }));
|
||||
}
|
||||
|
||||
/** Subscribe to a stream. Data pushes arrive via onStreamData handler. */
|
||||
async subscribe(stream: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "subscribe", stream }));
|
||||
}
|
||||
|
||||
/** Unsubscribe from a stream. */
|
||||
async unsubscribe(stream: string): Promise<void> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ type: "unsubscribe", stream }));
|
||||
}
|
||||
|
||||
/** List active streams in the mesh. */
|
||||
async listStreams(): Promise<Array<{ id: string; name: string; createdBy: string; subscriberCount: number }>> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
|
||||
return new Promise((resolve) => {
|
||||
this.streamListResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "list_streams" }));
|
||||
setTimeout(() => {
|
||||
const idx = this.streamListResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.streamListResolvers.splice(idx, 1); resolve([]); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Subscribe to stream data pushes. Returns an unsubscribe function. */
|
||||
onStreamData(handler: (data: { stream: string; data: unknown; publishedBy: string }) => void): () => void {
|
||||
this.streamDataHandlers.add(handler);
|
||||
return () => this.streamDataHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/** Subscribe to state change notifications. Returns an unsubscribe function. */
|
||||
onStateChange(handler: (change: { key: string; value: unknown; updatedBy: string }) => void): () => void {
|
||||
this.stateChangeHandlers.add(handler);
|
||||
return () => this.stateChangeHandlers.delete(handler);
|
||||
}
|
||||
|
||||
// --- Mesh info ---
|
||||
private meshInfoResolvers: Array<(result: Record<string, unknown> | null) => void> = [];
|
||||
|
||||
async meshInfo(): Promise<Record<string, unknown> | null> {
|
||||
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||
return new Promise((resolve) => {
|
||||
this.meshInfoResolvers.push(resolve);
|
||||
this.ws!.send(JSON.stringify({ type: "mesh_info" }));
|
||||
setTimeout(() => {
|
||||
const idx = this.meshInfoResolvers.indexOf(resolve);
|
||||
if (idx !== -1) { this.meshInfoResolvers.splice(idx, 1); resolve(null); }
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||
@@ -698,6 +961,100 @@ export class BrokerClient {
|
||||
if (resolver) resolver(accesses);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "vector_stored") {
|
||||
const resolver = this.vectorStoredResolvers.shift();
|
||||
if (resolver) resolver(msg.id ? String(msg.id) : null);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "vector_results") {
|
||||
const results = (msg.results as Array<{ id: string; text: string; score: number; metadata?: Record<string, unknown> }>) ?? [];
|
||||
const resolver = this.vectorResultsResolvers.shift();
|
||||
if (resolver) resolver(results);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "collection_list") {
|
||||
const collections = (msg.collections as string[]) ?? [];
|
||||
const resolver = this.collectionListResolvers.shift();
|
||||
if (resolver) resolver(collections);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "graph_result") {
|
||||
const rows = (msg.rows as Array<Record<string, unknown>>) ?? [];
|
||||
const resolver = this.graphResultResolvers.shift();
|
||||
if (resolver) resolver(rows);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "context_list") {
|
||||
const contexts = (msg.contexts as Array<{ peerName: string; summary: string; tags: string[]; updatedAt: string }>) ?? [];
|
||||
const resolver = this.contextListResolvers.shift();
|
||||
if (resolver) resolver(contexts);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "context_results") {
|
||||
const contexts = (msg.contexts as Array<{ peerName: string; summary: string; filesRead: string[]; keyFindings: string[]; tags: string[]; updatedAt: string }>) ?? [];
|
||||
const resolver = this.contextResultsResolvers.shift();
|
||||
if (resolver) resolver(contexts);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "task_created") {
|
||||
const resolver = this.taskCreatedResolvers.shift();
|
||||
if (resolver) resolver(msg.id ? String(msg.id) : null);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "task_list") {
|
||||
const tasks = (msg.tasks as Array<{ id: string; title: string; assignee: string; status: string; priority: string; createdBy: string }>) ?? [];
|
||||
const resolver = this.taskListResolvers.shift();
|
||||
if (resolver) resolver(tasks);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "mesh_query_result") {
|
||||
const resolver = this.meshQueryResolvers.shift();
|
||||
if (resolver) {
|
||||
if (msg.columns) {
|
||||
resolver({
|
||||
columns: (msg.columns as string[]) ?? [],
|
||||
rows: (msg.rows as Array<Record<string, unknown>>) ?? [],
|
||||
rowCount: (msg.rowCount as number) ?? 0,
|
||||
});
|
||||
} else {
|
||||
resolver(null);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === "mesh_schema_result") {
|
||||
const tables = (msg.tables as Array<{ name: string; columns: Array<{ name: string; type: string; nullable: boolean }> }>) ?? [];
|
||||
const resolver = this.meshSchemaResolvers.shift();
|
||||
if (resolver) resolver(tables);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "stream_created") {
|
||||
const resolver = this.streamCreatedResolvers.shift();
|
||||
if (resolver) resolver(msg.id ? String(msg.id) : null);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "stream_list") {
|
||||
const streams = (msg.streams as Array<{ id: string; name: string; createdBy: string; subscriberCount: number }>) ?? [];
|
||||
const resolver = this.streamListResolvers.shift();
|
||||
if (resolver) resolver(streams);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "stream_data") {
|
||||
const evt = {
|
||||
stream: String(msg.stream ?? ""),
|
||||
data: msg.data,
|
||||
publishedBy: String(msg.publishedBy ?? ""),
|
||||
};
|
||||
for (const h of this.streamDataHandlers) {
|
||||
try { h(evt); } catch { /* handler errors are not the transport's problem */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === "mesh_info_result") {
|
||||
const resolver = this.meshInfoResolvers.shift();
|
||||
if (resolver) resolver(msg as Record<string, unknown>);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "error") {
|
||||
this.debug(`broker error: ${msg.code} ${msg.message}`);
|
||||
const id = msg.id ? String(msg.id) : null;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { withPayload } = require("@payloadcms/next/withPayload");
|
||||
|
||||
import env from "./env.config";
|
||||
|
||||
const INTERNAL_PACKAGES = [
|
||||
@@ -130,4 +133,4 @@ const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
||||
enabled: env.ANALYZE,
|
||||
});
|
||||
|
||||
export default withBundleAnalyzer(config);
|
||||
export default withPayload(withBundleAnalyzer(config));
|
||||
|
||||
14
apps/web/src/app/(payload)/layout.tsx
Normal file
14
apps/web/src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import "@payloadcms/next/css";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata = {
|
||||
title: "CMS — claudemesh",
|
||||
};
|
||||
|
||||
export default function PayloadLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/app/(payload)/payload/[[...segments]]/page.tsx
Normal file
14
apps/web/src/app/(payload)/payload/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck — Payload generates these types at build time
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
import config from "@payload-config";
|
||||
|
||||
type Args = { params: Promise<{ segments: string[] }> };
|
||||
|
||||
export const generateMetadata = ({ params }: Args) =>
|
||||
generatePageMetadata({ config, params });
|
||||
|
||||
export default function Page({ params }: Args) {
|
||||
return <RootPage config={config} params={params} importMap={importMap} />;
|
||||
}
|
||||
51
apps/web/src/app/(payload)/payload/importMap.js
Normal file
51
apps/web/src/app/(payload)/payload/importMap.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
@@ -48,6 +48,41 @@ services:
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant
|
||||
restart: always
|
||||
volumes:
|
||||
- qdrant-data:/qdrant/storage
|
||||
expose:
|
||||
- "6333"
|
||||
networks:
|
||||
- claudemesh-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:6333/readyz"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
neo4j:
|
||||
image: neo4j:5
|
||||
restart: always
|
||||
environment:
|
||||
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-changeme}
|
||||
NEO4J_PLUGINS: '[]'
|
||||
volumes:
|
||||
- neo4j-data:/data
|
||||
expose:
|
||||
- "7687"
|
||||
- "7474"
|
||||
networks:
|
||||
- claudemesh-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "cypher-shell", "-u", "neo4j", "-p", "${NEO4J_PASSWORD:-changeme}", "RETURN 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 3
|
||||
|
||||
broker:
|
||||
image: ${BROKER_IMAGE:-claudemesh-broker:latest}
|
||||
restart: always
|
||||
@@ -64,6 +99,10 @@ services:
|
||||
MINIO_ACCESS_KEY: claudemesh
|
||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-changeme}
|
||||
MINIO_USE_SSL: "false"
|
||||
QDRANT_URL: http://qdrant:6333
|
||||
NEO4J_URL: bolt://neo4j:7687
|
||||
NEO4J_USER: neo4j
|
||||
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-changeme}
|
||||
expose:
|
||||
- "7900"
|
||||
networks:
|
||||
@@ -72,6 +111,10 @@ services:
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
neo4j:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "bun", "-e", "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
||||
interval: 15s
|
||||
@@ -114,6 +157,8 @@ services:
|
||||
|
||||
volumes:
|
||||
minio-data:
|
||||
qdrant-data:
|
||||
neo4j-data:
|
||||
|
||||
networks:
|
||||
# Coolify's shared Traefik network — must already exist on the host
|
||||
|
||||
33
packages/db/migrations/0010_add-context-and-tasks.sql
Normal file
33
packages/db/migrations/0010_add-context-and-tasks.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE "mesh"."context" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"mesh_id" text NOT NULL,
|
||||
"presence_id" text,
|
||||
"peer_name" text,
|
||||
"summary" text NOT NULL,
|
||||
"files_read" text[] DEFAULT '{}',
|
||||
"key_findings" text[] DEFAULT '{}',
|
||||
"tags" text[] DEFAULT '{}',
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "mesh"."task" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"mesh_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"assignee" text,
|
||||
"claimed_by_name" text,
|
||||
"claimed_by_presence" text,
|
||||
"priority" text DEFAULT 'normal' NOT NULL,
|
||||
"status" text DEFAULT 'open' NOT NULL,
|
||||
"tags" text[] DEFAULT '{}',
|
||||
"result" text,
|
||||
"created_by_name" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"claimed_at" timestamp,
|
||||
"completed_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "mesh"."context" ADD CONSTRAINT "context_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "mesh"."context" ADD CONSTRAINT "context_presence_id_presence_id_fk" FOREIGN KEY ("presence_id") REFERENCES "mesh"."presence"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "mesh"."task" ADD CONSTRAINT "task_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "mesh"."task" ADD CONSTRAINT "task_claimed_by_presence_presence_id_fk" FOREIGN KEY ("claimed_by_presence") REFERENCES "mesh"."presence"("id") ON DELETE no action ON UPDATE no action;
|
||||
10
packages/db/migrations/0011_add-streams.sql
Normal file
10
packages/db/migrations/0011_add-streams.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "mesh"."stream" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"mesh_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"created_by_name" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "mesh"."stream" ADD CONSTRAINT "stream_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "stream_mesh_name_idx" ON "mesh"."stream" USING btree ("mesh_id","name");
|
||||
3467
packages/db/migrations/meta/0010_snapshot.json
Normal file
3467
packages/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3548
packages/db/migrations/meta/0011_snapshot.json
Normal file
3548
packages/db/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,20 @@
|
||||
"when": 1775480008546,
|
||||
"tag": "0009_add-file-tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1775480729014,
|
||||
"tag": "0010_add-context-and-tasks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1775481222701,
|
||||
"tag": "0011_add-streams",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -327,6 +327,68 @@ export const meshFileAccess = meshSchema.table("file_access", {
|
||||
accessedAt: timestamp().defaultNow().notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-peer context snapshot. Each peer (presence) has at most one context
|
||||
* entry per mesh, upserted on each share_context call. Allows peers to
|
||||
* discover what others are working on, which files they've read, and
|
||||
* key findings — without sending a direct message.
|
||||
*/
|
||||
export const meshContext = meshSchema.table("context", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
presenceId: text().references(() => presence.id, { onDelete: "cascade" }),
|
||||
peerName: text(),
|
||||
summary: text().notNull(),
|
||||
filesRead: text().array().default([]),
|
||||
keyFindings: text().array().default([]),
|
||||
tags: text().array().default([]),
|
||||
updatedAt: timestamp().defaultNow().notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mesh-scoped task board. Peers can create tasks, claim them, and mark
|
||||
* them done. Lightweight project management for multi-agent workflows.
|
||||
*/
|
||||
export const meshTask = meshSchema.table("task", {
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
title: text().notNull(),
|
||||
assignee: text(),
|
||||
claimedByName: text(),
|
||||
claimedByPresence: text().references(() => presence.id),
|
||||
priority: text().notNull().default("normal"),
|
||||
status: text().notNull().default("open"),
|
||||
tags: text().array().default([]),
|
||||
result: text(),
|
||||
createdByName: text(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
claimedAt: timestamp(),
|
||||
completedAt: timestamp(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Named real-time data channels within a mesh. One peer publishes, all
|
||||
* subscribers receive. No message history — streams are live.
|
||||
* Use cases: build logs, deploy status, monitoring data, live code diffs.
|
||||
*/
|
||||
export const meshStream = meshSchema.table(
|
||||
"stream",
|
||||
{
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||
.notNull(),
|
||||
name: text().notNull(),
|
||||
createdByName: text(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("stream_mesh_name_idx").on(table.meshId, table.name)],
|
||||
);
|
||||
|
||||
export const meshRelations = relations(mesh, ({ one, many }) => ({
|
||||
owner: one(user, {
|
||||
fields: [mesh.ownerUserId],
|
||||
@@ -469,3 +531,45 @@ export type SelectMeshFile = typeof meshFile.$inferSelect;
|
||||
export type InsertMeshFile = typeof meshFile.$inferInsert;
|
||||
export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect;
|
||||
export type InsertMeshFileAccess = typeof meshFileAccess.$inferInsert;
|
||||
export const selectMeshContextSchema = createSelectSchema(meshContext);
|
||||
export const insertMeshContextSchema = createInsertSchema(meshContext);
|
||||
export const selectMeshTaskSchema = createSelectSchema(meshTask);
|
||||
export const insertMeshTaskSchema = createInsertSchema(meshTask);
|
||||
export type SelectMeshContext = typeof meshContext.$inferSelect;
|
||||
export type InsertMeshContext = typeof meshContext.$inferInsert;
|
||||
export type SelectMeshTask = typeof meshTask.$inferSelect;
|
||||
export type InsertMeshTask = typeof meshTask.$inferInsert;
|
||||
|
||||
export const meshContextRelations = relations(meshContext, ({ one }) => ({
|
||||
mesh: one(mesh, {
|
||||
fields: [meshContext.meshId],
|
||||
references: [mesh.id],
|
||||
}),
|
||||
presence: one(presence, {
|
||||
fields: [meshContext.presenceId],
|
||||
references: [presence.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const meshTaskRelations = relations(meshTask, ({ one }) => ({
|
||||
mesh: one(mesh, {
|
||||
fields: [meshTask.meshId],
|
||||
references: [mesh.id],
|
||||
}),
|
||||
claimedPresence: one(presence, {
|
||||
fields: [meshTask.claimedByPresence],
|
||||
references: [presence.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const meshStreamRelations = relations(meshStream, ({ one }) => ({
|
||||
mesh: one(mesh, {
|
||||
fields: [meshStream.meshId],
|
||||
references: [mesh.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const selectMeshStreamSchema = createSelectSchema(meshStream);
|
||||
export const insertMeshStreamSchema = createInsertSchema(meshStream);
|
||||
export type SelectMeshStream = typeof meshStream.$inferSelect;
|
||||
export type InsertMeshStream = typeof meshStream.$inferInsert;
|
||||
|
||||
Reference in New Issue
Block a user