Add the media MCP tools and live-verified slopbot path
This commit is contained in:
+6
-4
@@ -32,10 +32,12 @@ mobile draft at 390px.
|
|||||||
- [x] C6 Media and Bot tabs in the Web UI
|
- [x] C6 Media and Bot tabs in the Web UI
|
||||||
- [x] C7a storage limits, retention, warning/confirm flow, storage and clean API
|
- [x] C7a storage limits, retention, warning/confirm flow, storage and clean API
|
||||||
- [x] C7b Settings group: limit field with info hover, usage bar, clean modal
|
- [x] C7b Settings group: limit field with info hover, usage bar, clean modal
|
||||||
- [ ] C8 MCP service hooks
|
- [x] C8 MCP service hooks (`scrape_media`, scrape reads, storage, clean; reads in `run_commands`)
|
||||||
- [~] C9 live verification: slopbot login works (Slopbot#5775), but the bot is in 0 guilds, so
|
- [~] C9 live verification. Slopbot path verified live on 2026-09-16: login Slopbot#5775, guild
|
||||||
the test channel fetch fails with Missing Access. Waiting on the owner to invite the bot to
|
`177931418704281600`, test channel `1549681689222053988`. Scrape `1549681689222053988-
|
||||||
guild `177931418704281600`. Live check: `pnpm test:slopbot:live`.
|
20260916075150`: state done, 1 page, 17 messages, 4 images + 13 videos, 0 errors, ~20 MB of
|
||||||
|
indexed source sizes. Still owed: the puppet `message.fetch` field check, a `download` mode
|
||||||
|
run, a real 429 observation, and pacing tuning.
|
||||||
- Flextalk voice profile, end to end. The profile window is a movable panel that recenters on each
|
- Flextalk voice profile, end to end. The profile window is a movable panel that recenters on each
|
||||||
open; it carries the character sliders with engine defaults and +/-2 snapping, five named
|
open; it carries the character sliders with engine defaults and +/-2 snapping, five named
|
||||||
presets, radio-kit effects (high-pass 40-400 Hz, echo 0-100, word mode with a gap in ms), and
|
presets, radio-kit effects (high-pass 40-400 Hz, echo 0-100, word mode with a gap in ms), and
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ The daemon exposes MCP over Streamable HTTP. The default address is `http://127.
|
|||||||
| `list_dms` | List cached DM and group DM channels with recipients. |
|
| `list_dms` | List cached DM and group DM channels with recipients. |
|
||||||
| `get_message_log` | Read the trigger log. |
|
| `get_message_log` | Read the trigger log. |
|
||||||
| `get_action_log` | Read the persistent action log. |
|
| `get_action_log` | Read the persistent action log. |
|
||||||
|
| `scrape_media` | Start a media scrape of a channel (`channelId`, `guildId?`, `mode?`, `confirm?`). |
|
||||||
|
| `list_media_scrapes` | Media scrapes with state, pages, messages, and counts. |
|
||||||
|
| `get_media_scrape` | One scrape by ID. |
|
||||||
|
| `stop_media_scrape` | Stop a scrape; it pauses after the current page. |
|
||||||
|
| `get_media_storage` | Usage against the limit, per-kind sizes, and the scrape list. |
|
||||||
|
| `query_media` | Media items across scrapes (`kind?`, `scrapeId?`, `limit?`, `offset?`). |
|
||||||
|
| `get_media_item` | One media item by `scrapeId` and `itemId`. |
|
||||||
|
| `delete_media_scrape` | Delete a scrape and its files. Irreversible. |
|
||||||
| `run_commands` | Run several read-only tools in one call, in order. |
|
| `run_commands` | Run several read-only tools in one call, in order. |
|
||||||
| `get_context` | Assemble conversation context for a channel. Fetches new messages by default. |
|
| `get_context` | Assemble conversation context for a channel. Fetches new messages by default. |
|
||||||
| `get_puppet_config` | Read the response policy and context settings. |
|
| `get_puppet_config` | Read the response policy and context settings. |
|
||||||
|
|||||||
+5
-3
@@ -4,7 +4,9 @@ The media archive is designed for agent access. The daemon exposes one service,
|
|||||||
are thin wrappers around it. The same service backs the Web UI routes, so the two clients cannot
|
are thin wrappers around it. The same service backs the Web UI routes, so the two clients cannot
|
||||||
drift.
|
drift.
|
||||||
|
|
||||||
Status: hooks designed, implementation at checkpoint C8 in [CURRENT.md](../CURRENT.md).
|
Status: implemented (C8). The tools are registered in `poc/src/control-bridge.ts`; read tools are
|
||||||
|
also in `run_commands`. Advanced `query_media` filters (author, date, text) are planned; the index
|
||||||
|
has the columns.
|
||||||
|
|
||||||
## Service interface
|
## Service interface
|
||||||
|
|
||||||
@@ -21,8 +23,8 @@ interface MediaService {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`MediaQuery`: `kind`, `scrapeId`, `channelId`, `guildId`, `authorId`, `after`, `before`, `text`,
|
`MediaQuery` (implemented): `kind`, `scrapeId`, `limit`, `offset`. Planned: `channelId`,
|
||||||
`limit`, `cursor`.
|
`guildId`, `authorId`, `after`, `before`, `text`.
|
||||||
|
|
||||||
`StorageStatus`: `usedBytes`, `limitBytes`, `warnPercent`, `overrunPercent`, `byKind`,
|
`StorageStatus`: `usedBytes`, `limitBytes`, `warnPercent`, `overrunPercent`, `byKind`,
|
||||||
`scrapes: [{ id, bytes, state, createdAt }]`.
|
`scrapes: [{ id, bytes, state, createdAt }]`.
|
||||||
|
|||||||
+4
-2
@@ -1,8 +1,10 @@
|
|||||||
# Media archive
|
# Media archive
|
||||||
|
|
||||||
Status: design approved 2026-09-16. The engine, the slopbot backend, the `scrape media` command,
|
Status: design approved 2026-09-16. The engine, the slopbot backend, the `scrape media` command,
|
||||||
the `/api/media/*` routes, storage limits with retention, and the Settings group are implemented
|
the `/api/media/*` routes, storage limits with retention, the Settings group, and the MCP tools are
|
||||||
(C3-C7b). The Media and Bot tabs are C6. Checkpoints live in [CURRENT.md](../CURRENT.md).
|
implemented (C3-C8). The Media and Bot tabs are C6. The slopbot path is live-verified: the test
|
||||||
|
channel produced 17 messages and 4 images + 13 videos with no errors. The puppet path and a
|
||||||
|
`download` run are still owed (C9). Checkpoints live in [CURRENT.md](../CURRENT.md).
|
||||||
|
|
||||||
The media archive saves the media history of a channel. A trusted user runs `scrape media` in the
|
The media archive saves the media history of a channel. A trusted user runs `scrape media` in the
|
||||||
channel. The daemon walks the history through a scrape backend and indexes every image, GIF,
|
channel. The daemon walks the history through a scrape backend and indexes every image, GIF,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Setup and security: docs/slopbot.md.
|
* Setup and security: docs/slopbot.md.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Client, GatewayIntentBits } from "discord.js";
|
import { Client, Events, GatewayIntentBits } from "discord.js";
|
||||||
import type { SlopbotConfig } from "./config.js";
|
import type { SlopbotConfig } from "./config.js";
|
||||||
import type { SlopbotFetcher } from "./backend.js";
|
import type { SlopbotFetcher } from "./backend.js";
|
||||||
|
|
||||||
@@ -23,7 +23,16 @@ export async function startSlopbot(config: SlopbotConfig): Promise<Client> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||||
|
|
||||||
|
// login() resolves before the READY dispatch, so wait for it: the guild cache
|
||||||
|
// and client.user are only populated then.
|
||||||
|
const ready = new Promise<void>((resolve, reject) => {
|
||||||
|
client.once(Events.ClientReady, () => resolve());
|
||||||
|
client.once(Events.Error, reject);
|
||||||
|
});
|
||||||
|
|
||||||
await client.login(config.token);
|
await client.login(config.token);
|
||||||
|
await ready;
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ CREATE TABLE IF NOT EXISTS media (
|
|||||||
preview_url TEXT,
|
preview_url TEXT,
|
||||||
file_hash TEXT,
|
file_hash TEXT,
|
||||||
preview_hash TEXT,
|
preview_hash TEXT,
|
||||||
|
file_bytes INTEGER,
|
||||||
|
preview_bytes INTEGER,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -155,6 +157,16 @@ export class ScrapeStore {
|
|||||||
constructor(readonly dir: string) {
|
constructor(readonly dir: string) {
|
||||||
this.db = new DatabaseSync(join(dir, "index.db"));
|
this.db = new DatabaseSync(join(dir, "index.db"));
|
||||||
this.db.exec(SCHEMA);
|
this.db.exec(SCHEMA);
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add columns introduced after a scrape was created. */
|
||||||
|
private migrate(): void {
|
||||||
|
const columns = this.db.prepare("PRAGMA table_info(media)").all() as unknown as Array<{ name: string }>;
|
||||||
|
const names = new Set(columns.map(column => column.name));
|
||||||
|
|
||||||
|
if (!names.has("file_bytes")) this.db.exec("ALTER TABLE media ADD COLUMN file_bytes INTEGER");
|
||||||
|
if (!names.has("preview_bytes")) this.db.exec("ALTER TABLE media ADD COLUMN preview_bytes INTEGER");
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
@@ -216,10 +228,12 @@ export class ScrapeStore {
|
|||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sum of the recorded source sizes per kind. Used by the storage bar. */
|
/** Bytes actually on disk per kind (downloaded files and cached previews). */
|
||||||
sumBytesByKind(): Record<MediaKind, number> {
|
storedBytesByKind(): Record<MediaKind, number> {
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.prepare("SELECT kind, COALESCE(SUM(size), 0) AS bytes FROM media GROUP BY kind")
|
.prepare(
|
||||||
|
"SELECT kind, COALESCE(SUM(COALESCE(file_bytes, 0) + COALESCE(preview_bytes, 0)), 0) AS bytes FROM media GROUP BY kind"
|
||||||
|
)
|
||||||
.all() as unknown as Array<{ kind: string; bytes: number }>;
|
.all() as unknown as Array<{ kind: string; bytes: number }>;
|
||||||
|
|
||||||
const result = emptyMediaCounts();
|
const result = emptyMediaCounts();
|
||||||
@@ -241,6 +255,13 @@ export class ScrapeStore {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMedia(id: number): MediaRow | null {
|
||||||
|
const row = this.db.prepare("SELECT * FROM media WHERE id = ?").get(id) as unknown as
|
||||||
|
| MediaRow
|
||||||
|
| undefined;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
queryMedia(options: { kind?: MediaKind; limit?: number; offset?: number } = {}): MediaRow[] {
|
queryMedia(options: { kind?: MediaKind; limit?: number; offset?: number } = {}): MediaRow[] {
|
||||||
const limit = Math.max(1, Math.min(options.limit ?? 50, 500));
|
const limit = Math.max(1, Math.min(options.limit ?? 50, 500));
|
||||||
const offset = Math.max(0, options.offset ?? 0);
|
const offset = Math.max(0, options.offset ?? 0);
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ void (async () => {
|
|||||||
assert.equal(stored.counts().image, 1);
|
assert.equal(stored.counts().image, 1);
|
||||||
assert.equal(stored.queryMedia({ kind: "image" }).length, 1);
|
assert.equal(stored.queryMedia({ kind: "image" }).length, 1);
|
||||||
assert.equal(stored.queryMedia({ kind: "video" }).length, 0);
|
assert.equal(stored.queryMedia({ kind: "video" }).length, 0);
|
||||||
assert.equal(stored.sumBytesByKind().image, 10);
|
assert.equal(stored.storedBytesByKind().image, 0);
|
||||||
stored.close();
|
stored.close();
|
||||||
|
|
||||||
assert.equal(listScrapes(root).length, 1);
|
assert.equal(listScrapes(root).length, 1);
|
||||||
|
|||||||
+101
-1
@@ -317,7 +317,16 @@ const READ_ONLY_TOOLS: Record<string, (args: any) => Promise<unknown> | unknown>
|
|||||||
get_action_log: args => requirePuppet().readActionLog(args?.limit ?? 100),
|
get_action_log: args => requirePuppet().readActionLog(args?.limit ?? 100),
|
||||||
get_puppet_config: () => requirePuppet().getConfig(),
|
get_puppet_config: () => requirePuppet().getConfig(),
|
||||||
whitelist_list: () => ({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }),
|
whitelist_list: () => ({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }),
|
||||||
read_messages: args => paced(() => requireCore().readMessages(args?.channelId, args?.limit ?? 20))
|
read_messages: args => paced(() => requireCore().readMessages(args?.channelId, args?.limit ?? 20)),
|
||||||
|
list_media_scrapes: () => media.listScrapes(),
|
||||||
|
get_media_storage: () => media.refreshRetention(),
|
||||||
|
query_media: args =>
|
||||||
|
media.queryMedia({
|
||||||
|
kind: args?.kind,
|
||||||
|
scrapeId: args?.scrapeId,
|
||||||
|
limit: args?.limit,
|
||||||
|
offset: args?.offset
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildServer(): McpServer {
|
function buildServer(): McpServer {
|
||||||
@@ -749,6 +758,97 @@ function buildServer(): McpServer {
|
|||||||
async ({ userIds }) => jsonText({ whitelist: requirePuppet().removeFromWhitelist(userIds) })
|
async ({ userIds }) => jsonText({ whitelist: requirePuppet().removeFromWhitelist(userIds) })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"scrape_media",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Start a media scrape of a channel. Uses slopbot for guild channels it is in, otherwise the client. Near the storage limit it fails with MEDIA_LIMIT unless confirm is true.",
|
||||||
|
inputSchema: {
|
||||||
|
channelId: z.string().describe("Channel ID to scrape"),
|
||||||
|
guildId: z.string().optional().describe("Guild ID; slopbot needs it to match"),
|
||||||
|
mode: z
|
||||||
|
.enum(["links", "download"])
|
||||||
|
.optional()
|
||||||
|
.describe("links indexes URLs (default); download fetches attachment bytes"),
|
||||||
|
confirm: z.boolean().optional().describe("Accept the near-limit warning")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ channelId, guildId, mode, confirm }) =>
|
||||||
|
jsonText(await media.startScrape({ channelId, guildId: guildId ?? null, mode, confirm }))
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"list_media_scrapes",
|
||||||
|
{ description: "List media scrapes with state, pages, messages, and media counts." },
|
||||||
|
async () => jsonText(media.listScrapes())
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_media_scrape",
|
||||||
|
{
|
||||||
|
description: "Get one media scrape by ID.",
|
||||||
|
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
|
||||||
|
},
|
||||||
|
async ({ scrapeId }) => jsonText(media.getScrape(scrapeId))
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"stop_media_scrape",
|
||||||
|
{
|
||||||
|
description: "Stop a media scrape. It pauses after the current page.",
|
||||||
|
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
|
||||||
|
},
|
||||||
|
async ({ scrapeId }) => {
|
||||||
|
media.stopScrape(scrapeId);
|
||||||
|
return jsonText({ stopped: scrapeId });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_media_storage",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Media storage usage against the limit, per-kind sizes, and the scrape list. Marks old scrapes when over the limit."
|
||||||
|
},
|
||||||
|
async () => jsonText(media.refreshRetention())
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"query_media",
|
||||||
|
{
|
||||||
|
description: "Query media items across scrapes, newest scrape first.",
|
||||||
|
inputSchema: {
|
||||||
|
kind: z.enum(["image", "gif", "video", "audio"]).optional(),
|
||||||
|
scrapeId: z.string().optional().describe("Restrict to one scrape"),
|
||||||
|
limit: z.number().int().min(1).max(500).optional(),
|
||||||
|
offset: z.number().int().min(0).optional()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ kind, scrapeId, limit, offset }) =>
|
||||||
|
jsonText(media.queryMedia({ kind, scrapeId, limit, offset }))
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"get_media_item",
|
||||||
|
{
|
||||||
|
description: "Get one media item by scrape ID and item ID.",
|
||||||
|
inputSchema: {
|
||||||
|
scrapeId: z.string().describe("Scrape ID"),
|
||||||
|
itemId: z.number().int().min(1).describe("Media row ID inside the scrape")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ scrapeId, itemId }) => jsonText(media.getMediaItem(scrapeId, itemId))
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"delete_media_scrape",
|
||||||
|
{
|
||||||
|
description: "Delete a scrape and its files. Irreversible.",
|
||||||
|
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
|
||||||
|
},
|
||||||
|
async ({ scrapeId }) => jsonText({ deleted: media.cleanMedia([scrapeId]) === 1 })
|
||||||
|
);
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -15,6 +15,7 @@ import {
|
|||||||
scrapeDir,
|
scrapeDir,
|
||||||
resolveDbRoot,
|
resolveDbRoot,
|
||||||
type MediaKind,
|
type MediaKind,
|
||||||
|
type MediaRow,
|
||||||
type ScrapeBackend,
|
type ScrapeBackend,
|
||||||
type ScrapeMeta
|
type ScrapeMeta
|
||||||
} from "../../media-scrape/src/index.js";
|
} from "../../media-scrape/src/index.js";
|
||||||
@@ -160,7 +161,7 @@ export class MediaService {
|
|||||||
try {
|
try {
|
||||||
const store = new ScrapeStore(dir);
|
const store = new ScrapeStore(dir);
|
||||||
try {
|
try {
|
||||||
const sums = store.sumBytesByKind();
|
const sums = store.storedBytesByKind();
|
||||||
for (const kind of Object.keys(byKind) as MediaKind[]) byKind[kind] += sums[kind];
|
for (const kind of Object.keys(byKind) as MediaKind[]) byKind[kind] += sums[kind];
|
||||||
} finally {
|
} finally {
|
||||||
store.close();
|
store.close();
|
||||||
@@ -279,8 +280,24 @@ export class MediaService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One media row inside a scrape, with its scrape and channel for context. */
|
||||||
|
getMediaItem(scrapeId: string, itemId: number): (MediaRow & { scrapeId: string; channelId: string }) | null {
|
||||||
|
const meta = this.engine.get(scrapeId);
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
const store = new ScrapeStore(scrapeDir(this.rootDir, scrapeId));
|
||||||
|
try {
|
||||||
|
const row = store.getMedia(itemId);
|
||||||
|
return row ? { ...row, scrapeId, channelId: meta.channelId } : null;
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Read media rows across every scrape, newest scrape first. */
|
/** Read media rows across every scrape, newest scrape first. */
|
||||||
queryMedia(options: { kind?: MediaKind; limit?: number; offset?: number } = {}): MediaItemResult[] {
|
queryMedia(
|
||||||
|
options: { kind?: MediaKind; scrapeId?: string; limit?: number; offset?: number } = {}
|
||||||
|
): MediaItemResult[] {
|
||||||
const limit = Math.max(1, Math.min(options.limit ?? 60, 500));
|
const limit = Math.max(1, Math.min(options.limit ?? 60, 500));
|
||||||
const offset = Math.max(0, options.offset ?? 0);
|
const offset = Math.max(0, options.offset ?? 0);
|
||||||
const results: MediaItemResult[] = [];
|
const results: MediaItemResult[] = [];
|
||||||
@@ -288,6 +305,7 @@ export class MediaService {
|
|||||||
|
|
||||||
for (const meta of this.engine.list()) {
|
for (const meta of this.engine.list()) {
|
||||||
if (meta.state === "deleted") continue;
|
if (meta.state === "deleted") continue;
|
||||||
|
if (options.scrapeId && meta.id !== options.scrapeId) continue;
|
||||||
|
|
||||||
const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id));
|
const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id));
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user