clementine-agent 1.0.71 → 1.0.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine — Connector Feed recipes.
|
|
3
|
+
*
|
|
4
|
+
* Each recipe is a blueprint for a one-click "auto-seed feed" that turns an
|
|
5
|
+
* authenticated Claude Desktop connector (Google Drive, Gmail, Outlook, etc.)
|
|
6
|
+
* into a scheduled data feed that writes into the brain's ingest folder.
|
|
7
|
+
*
|
|
8
|
+
* A feed materializes as:
|
|
9
|
+
* 1. A CRON.md job entry with `managed: connector-feed` frontmatter
|
|
10
|
+
* 2. (optional) A source registry row tying the target folder to the run
|
|
11
|
+
*
|
|
12
|
+
* The cron prompt tells the Claude Code agent to use the integration's MCP
|
|
13
|
+
* tools to pull records, then call `brain_ingest_folder` to commit them —
|
|
14
|
+
* which writes markdown files and runs the distillation pipeline in one step.
|
|
15
|
+
*
|
|
16
|
+
* Field syntax in prompt templates:
|
|
17
|
+
* {{fieldKey}} — user-supplied value
|
|
18
|
+
* {{slug}} — the feed's computed slug (used for folder + dedup)
|
|
19
|
+
*/
|
|
20
|
+
export interface RecipeField {
|
|
21
|
+
key: string;
|
|
22
|
+
label: string;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
help?: string;
|
|
25
|
+
required?: boolean;
|
|
26
|
+
defaultValue?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ConnectorRecipe {
|
|
29
|
+
/** Stable id, used as the job-name prefix. */
|
|
30
|
+
id: string;
|
|
31
|
+
/** Short human label shown in the recipe picker. */
|
|
32
|
+
label: string;
|
|
33
|
+
/** One-line description for the UI card. */
|
|
34
|
+
description: string;
|
|
35
|
+
/** Emoji shown next to the label. */
|
|
36
|
+
icon: string;
|
|
37
|
+
/** Matches the key in ~/.clementine/claude-integrations.json */
|
|
38
|
+
integration: string;
|
|
39
|
+
/** Tools we rely on for this recipe. Used only to warn if the integration
|
|
40
|
+
* hasn't surfaced them yet in claude-integrations.json. */
|
|
41
|
+
requiredTools: string[];
|
|
42
|
+
/** User-visible form fields shown in the wizard. */
|
|
43
|
+
fields: RecipeField[];
|
|
44
|
+
/** Default cron expression when the user picks "Daily / Hourly / etc." */
|
|
45
|
+
defaultSchedule: string;
|
|
46
|
+
/** Which cron tier to run at (1 = auto, 2 = logged, 3 = approval). Feeds
|
|
47
|
+
* typically need tier 2 because they call external MCP tools and write
|
|
48
|
+
* into the vault. */
|
|
49
|
+
tier: number;
|
|
50
|
+
/** Build the cron prompt from the user's field values + derived slug. */
|
|
51
|
+
buildPrompt: (vals: Record<string, string>, ctx: {
|
|
52
|
+
slug: string;
|
|
53
|
+
targetFolder: string;
|
|
54
|
+
}) => string;
|
|
55
|
+
/** Compute the slug from field values. Must be URL-safe. */
|
|
56
|
+
slugFromValues: (vals: Record<string, string>) => string;
|
|
57
|
+
}
|
|
58
|
+
export declare const RECIPES: ConnectorRecipe[];
|
|
59
|
+
export declare function recipeById(id: string): ConnectorRecipe | undefined;
|
|
60
|
+
export declare function recipesForIntegration(integration: string): ConnectorRecipe[];
|
|
61
|
+
/** Validate that all required fields are present. Returns missing keys. */
|
|
62
|
+
export declare function missingFields(recipe: ConnectorRecipe, values: Record<string, string>): string[];
|
|
63
|
+
/** Produce { slug, targetFolder, prompt, jobName } for a recipe + values. */
|
|
64
|
+
export declare function buildFeedSpec(recipe: ConnectorRecipe, values: Record<string, string>): {
|
|
65
|
+
slug: string;
|
|
66
|
+
targetFolder: string;
|
|
67
|
+
prompt: string;
|
|
68
|
+
jobName: string;
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=connector-recipes.d.ts.map
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine — Connector Feed recipes.
|
|
3
|
+
*
|
|
4
|
+
* Each recipe is a blueprint for a one-click "auto-seed feed" that turns an
|
|
5
|
+
* authenticated Claude Desktop connector (Google Drive, Gmail, Outlook, etc.)
|
|
6
|
+
* into a scheduled data feed that writes into the brain's ingest folder.
|
|
7
|
+
*
|
|
8
|
+
* A feed materializes as:
|
|
9
|
+
* 1. A CRON.md job entry with `managed: connector-feed` frontmatter
|
|
10
|
+
* 2. (optional) A source registry row tying the target folder to the run
|
|
11
|
+
*
|
|
12
|
+
* The cron prompt tells the Claude Code agent to use the integration's MCP
|
|
13
|
+
* tools to pull records, then call `brain_ingest_folder` to commit them —
|
|
14
|
+
* which writes markdown files and runs the distillation pipeline in one step.
|
|
15
|
+
*
|
|
16
|
+
* Field syntax in prompt templates:
|
|
17
|
+
* {{fieldKey}} — user-supplied value
|
|
18
|
+
* {{slug}} — the feed's computed slug (used for folder + dedup)
|
|
19
|
+
*/
|
|
20
|
+
function slugify(s) {
|
|
21
|
+
return String(s).toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
23
|
+
.replace(/^-+|-+$/g, '')
|
|
24
|
+
.slice(0, 40) || 'feed';
|
|
25
|
+
}
|
|
26
|
+
const COMMIT_INSTRUCTIONS = `When you have the records collected, call the \`brain_ingest_folder\` MCP tool with:
|
|
27
|
+
- \`slug\`: "{{slug}}"
|
|
28
|
+
- \`records\`: an array of \`{title, externalId, content, metadata}\` objects (one per item). \`externalId\` should be the source provider's stable id so re-runs dedup. \`metadata\` can include any fields you want preserved (url, modifiedAt, author).
|
|
29
|
+
|
|
30
|
+
That tool writes each record to \`{{targetFolder}}/\` and runs the brain's distillation pipeline. You do NOT need to use Write — brain_ingest_folder handles file creation. Finish by reporting a one-line summary like "Ingested N new records, M unchanged".
|
|
31
|
+
|
|
32
|
+
If the tool returns an error, include the error text in your summary.`;
|
|
33
|
+
// ── Recipes ────────────────────────────────────────────────────────────
|
|
34
|
+
export const RECIPES = [
|
|
35
|
+
{
|
|
36
|
+
id: 'gdrive-watch-folder',
|
|
37
|
+
label: 'Google Drive: watch a folder',
|
|
38
|
+
description: 'Pull new or modified files from a Drive folder on a schedule.',
|
|
39
|
+
icon: '📁',
|
|
40
|
+
integration: 'Google_Drive',
|
|
41
|
+
requiredTools: ['search_files', 'read_file_content'],
|
|
42
|
+
fields: [
|
|
43
|
+
{ key: 'folder', label: 'Folder name or partial path', placeholder: 'Active Projects', required: true,
|
|
44
|
+
help: 'The wizard searches Drive for folders matching this text. Exact name is safest.' },
|
|
45
|
+
],
|
|
46
|
+
defaultSchedule: '0 8 * * *',
|
|
47
|
+
tier: 2,
|
|
48
|
+
slugFromValues: (v) => `gdrive-${slugify(v.folder || 'folder')}`,
|
|
49
|
+
buildPrompt: (v, ctx) => `You are running the "${v.folder}" Google Drive feed.
|
|
50
|
+
|
|
51
|
+
Goal: find files in the Drive folder "${v.folder}" that are new or modified since the last run, read their content, and ingest them into the brain under slug "${ctx.slug}".
|
|
52
|
+
|
|
53
|
+
Steps:
|
|
54
|
+
1. Use \`search_files\` (Google Drive) to locate files in or under a folder named "${v.folder}". Limit to the top 25 most recently modified files.
|
|
55
|
+
2. For each file, call \`read_file_content\` to get the full body. If a file is a binary/PDF and read_file_content returns an error, skip it with a note in the summary.
|
|
56
|
+
3. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
|
|
57
|
+
`,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'gdrive-recent',
|
|
61
|
+
label: 'Google Drive: my recent files',
|
|
62
|
+
description: 'Daily snapshot of files you\'ve opened or edited recently.',
|
|
63
|
+
icon: '🕒',
|
|
64
|
+
integration: 'Google_Drive',
|
|
65
|
+
requiredTools: ['list_recent_files', 'read_file_content'],
|
|
66
|
+
fields: [
|
|
67
|
+
{ key: 'limit', label: 'How many recent files', placeholder: '15', defaultValue: '15' },
|
|
68
|
+
],
|
|
69
|
+
defaultSchedule: '0 9 * * *',
|
|
70
|
+
tier: 2,
|
|
71
|
+
slugFromValues: () => 'gdrive-recent',
|
|
72
|
+
buildPrompt: (v, ctx) => `You are running the "recent Google Drive files" feed.
|
|
73
|
+
|
|
74
|
+
Goal: capture the ${v.limit || '15'} most recently-modified Google Drive files as brain records.
|
|
75
|
+
|
|
76
|
+
Steps:
|
|
77
|
+
1. Use \`list_recent_files\` (Google Drive) with limit ${v.limit || '15'}.
|
|
78
|
+
2. For each file, call \`read_file_content\` to get the body. Skip files that error.
|
|
79
|
+
3. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
|
|
80
|
+
`,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'gmail-query',
|
|
84
|
+
label: 'Gmail: query watch',
|
|
85
|
+
description: 'Ingest emails matching a Gmail search query.',
|
|
86
|
+
icon: '✉️',
|
|
87
|
+
integration: 'Gmail',
|
|
88
|
+
requiredTools: ['search_messages', 'read_message'],
|
|
89
|
+
fields: [
|
|
90
|
+
{ key: 'query', label: 'Gmail query', placeholder: 'is:unread from:@stripe.com', required: true,
|
|
91
|
+
help: 'Standard Gmail search syntax (from:, to:, label:, has:attachment, newer_than:7d, etc.)' },
|
|
92
|
+
{ key: 'limit', label: 'Max messages per run', placeholder: '25', defaultValue: '25' },
|
|
93
|
+
],
|
|
94
|
+
defaultSchedule: '0 */4 * * *',
|
|
95
|
+
tier: 2,
|
|
96
|
+
slugFromValues: (v) => `gmail-${slugify(v.query || 'query')}`,
|
|
97
|
+
buildPrompt: (v, ctx) => `You are running the Gmail feed for query: \`${v.query}\`.
|
|
98
|
+
|
|
99
|
+
Goal: pull up to ${v.limit || '25'} messages matching this query and ingest their subjects, senders, and bodies into the brain.
|
|
100
|
+
|
|
101
|
+
Steps:
|
|
102
|
+
1. Use the Gmail MCP tool to search for messages matching \`${v.query}\`, limit ${v.limit || '25'}.
|
|
103
|
+
2. For each message, fetch the full body (plain text is fine). Keep the message id as \`externalId\` so re-runs dedup.
|
|
104
|
+
3. For each record, set \`title\` to the email subject, \`content\` to "From: <sender>\\nDate: <iso>\\n\\n<body>", and \`metadata\` to \`{from, to, date, messageId, labels}\`.
|
|
105
|
+
4. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
|
|
106
|
+
`,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'gcal-daily',
|
|
110
|
+
label: 'Google Calendar: daily events',
|
|
111
|
+
description: 'Snapshot today\'s calendar events as a single daily note.',
|
|
112
|
+
icon: '📅',
|
|
113
|
+
integration: 'Google_Calendar',
|
|
114
|
+
requiredTools: ['list_events'],
|
|
115
|
+
fields: [],
|
|
116
|
+
defaultSchedule: '0 7 * * *',
|
|
117
|
+
tier: 2,
|
|
118
|
+
slugFromValues: () => 'gcal-daily',
|
|
119
|
+
buildPrompt: (_v, ctx) => `You are running the daily Google Calendar feed.
|
|
120
|
+
|
|
121
|
+
Goal: capture today's calendar events as a single brain record so the agent has them at hand.
|
|
122
|
+
|
|
123
|
+
Steps:
|
|
124
|
+
1. Use \`list_events\` to fetch today's events (local timezone).
|
|
125
|
+
2. Build ONE record with:
|
|
126
|
+
- \`title\`: "Calendar — <today ISO date>"
|
|
127
|
+
- \`externalId\`: "gcal-<today ISO date>" (so each day gets one deterministic record)
|
|
128
|
+
- \`content\`: a markdown bullet list of each event (time, title, attendees, location)
|
|
129
|
+
- \`metadata\`: \`{date, eventCount}\`
|
|
130
|
+
3. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
|
|
131
|
+
`,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'outlook-inbox',
|
|
135
|
+
label: 'Outlook (Microsoft 365): inbox watch',
|
|
136
|
+
description: 'Pull recent Outlook emails into the brain.',
|
|
137
|
+
icon: '📥',
|
|
138
|
+
integration: 'Microsoft_365',
|
|
139
|
+
requiredTools: ['outlook_email_search', 'read_resource'],
|
|
140
|
+
fields: [
|
|
141
|
+
{ key: 'query', label: 'Search query (optional)', placeholder: 'received last 24h', defaultValue: 'received last 24h' },
|
|
142
|
+
{ key: 'limit', label: 'Max messages per run', placeholder: '25', defaultValue: '25' },
|
|
143
|
+
],
|
|
144
|
+
defaultSchedule: '0 */6 * * *',
|
|
145
|
+
tier: 2,
|
|
146
|
+
slugFromValues: (v) => `outlook-${slugify(v.query || 'inbox')}`,
|
|
147
|
+
buildPrompt: (v, ctx) => `You are running the Outlook inbox feed.
|
|
148
|
+
|
|
149
|
+
Goal: ingest up to ${v.limit || '25'} Outlook emails matching "${v.query || 'received last 24h'}" into the brain.
|
|
150
|
+
|
|
151
|
+
Steps:
|
|
152
|
+
1. Call \`outlook_email_search\` with query "${v.query || 'received last 24h'}", limit ${v.limit || '25'}.
|
|
153
|
+
2. For each result, use \`read_resource\` if needed to load the full body.
|
|
154
|
+
3. For each record: \`title\` = subject, \`externalId\` = the message id, \`content\` = "From: <sender>\\nDate: <iso>\\n\\n<body>", \`metadata\` = \`{from, to, date, messageId, folder}\`.
|
|
155
|
+
4. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
|
|
156
|
+
`,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: 'sharepoint-folder',
|
|
160
|
+
label: 'SharePoint: watch a folder',
|
|
161
|
+
description: 'Pull new or modified files from a SharePoint folder.',
|
|
162
|
+
icon: '📂',
|
|
163
|
+
integration: 'Microsoft_365',
|
|
164
|
+
requiredTools: ['sharepoint_folder_search', 'read_resource'],
|
|
165
|
+
fields: [
|
|
166
|
+
{ key: 'folder', label: 'Folder path or name', placeholder: 'Shared Documents/Proposals', required: true },
|
|
167
|
+
],
|
|
168
|
+
defaultSchedule: '0 8 * * *',
|
|
169
|
+
tier: 2,
|
|
170
|
+
slugFromValues: (v) => `sharepoint-${slugify(v.folder || 'folder')}`,
|
|
171
|
+
buildPrompt: (v, ctx) => `You are running the SharePoint feed for folder "${v.folder}".
|
|
172
|
+
|
|
173
|
+
Goal: find files in SharePoint folder "${v.folder}" that are new or modified since the last run and ingest them.
|
|
174
|
+
|
|
175
|
+
Steps:
|
|
176
|
+
1. Use \`sharepoint_folder_search\` to list files under "${v.folder}". Limit to top 25 most-recent.
|
|
177
|
+
2. For each file, use \`read_resource\` to fetch content.
|
|
178
|
+
3. For each record: \`externalId\` = the SharePoint item id, \`title\` = the file name, \`content\` = the extracted text, \`metadata\` = \`{path, modifiedAt, author, size}\`.
|
|
179
|
+
4. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
|
|
180
|
+
`,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'slack-channel',
|
|
184
|
+
label: 'Slack: channel archive',
|
|
185
|
+
description: 'Ingest messages from a Slack channel (once Slack is authenticated in Claude Desktop).',
|
|
186
|
+
icon: '💬',
|
|
187
|
+
integration: 'Slack',
|
|
188
|
+
requiredTools: ['search_messages', 'conversations_history'],
|
|
189
|
+
fields: [
|
|
190
|
+
{ key: 'channel', label: 'Channel name (without #)', placeholder: 'general', required: true },
|
|
191
|
+
{ key: 'limit', label: 'Max messages per run', placeholder: '50', defaultValue: '50' },
|
|
192
|
+
],
|
|
193
|
+
defaultSchedule: '0 18 * * *',
|
|
194
|
+
tier: 2,
|
|
195
|
+
slugFromValues: (v) => `slack-${slugify(v.channel || 'channel')}`,
|
|
196
|
+
buildPrompt: (v, ctx) => `You are running the Slack feed for channel #${v.channel}.
|
|
197
|
+
|
|
198
|
+
Goal: pull up to ${v.limit || '50'} recent messages from #${v.channel} and ingest them into the brain.
|
|
199
|
+
|
|
200
|
+
Steps:
|
|
201
|
+
1. Use the Slack MCP tools to list messages in #${v.channel} since the last run (or last 24h if this is the first run). Limit ${v.limit || '50'}.
|
|
202
|
+
2. For each message: \`externalId\` = the slack ts id, \`title\` = first 80 chars of the message, \`content\` = the full text, \`metadata\` = \`{channel, user, timestamp, threadTs}\`.
|
|
203
|
+
3. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
|
|
204
|
+
`,
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
export function recipeById(id) {
|
|
208
|
+
return RECIPES.find((r) => r.id === id);
|
|
209
|
+
}
|
|
210
|
+
export function recipesForIntegration(integration) {
|
|
211
|
+
return RECIPES.filter((r) => r.integration === integration);
|
|
212
|
+
}
|
|
213
|
+
/** Validate that all required fields are present. Returns missing keys. */
|
|
214
|
+
export function missingFields(recipe, values) {
|
|
215
|
+
return recipe.fields
|
|
216
|
+
.filter((f) => f.required && !(values[f.key] ?? '').trim())
|
|
217
|
+
.map((f) => f.key);
|
|
218
|
+
}
|
|
219
|
+
/** Produce { slug, targetFolder, prompt, jobName } for a recipe + values. */
|
|
220
|
+
export function buildFeedSpec(recipe, values) {
|
|
221
|
+
const slug = recipe.slugFromValues(values);
|
|
222
|
+
const targetFolder = `04-Ingest/${slug}`;
|
|
223
|
+
const jobName = `feed:${slug}`;
|
|
224
|
+
const prompt = recipe.buildPrompt(values, { slug, targetFolder });
|
|
225
|
+
return { slug, targetFolder, prompt, jobName };
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=connector-recipes.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -2549,6 +2549,202 @@ export async function cmdDashboard(opts) {
|
|
|
2549
2549
|
}
|
|
2550
2550
|
res.end();
|
|
2551
2551
|
});
|
|
2552
|
+
// ── Connector Feeds ────────────────────────────────────────────────
|
|
2553
|
+
//
|
|
2554
|
+
// A feed = a CRON.md job with `managed: connector-feed` frontmatter, plus
|
|
2555
|
+
// a source-registry row pointing at the feed's target folder. Feeds are
|
|
2556
|
+
// built from recipes in src/brain/connector-recipes.ts — the wizard in
|
|
2557
|
+
// the Intelligence → Sources tab composes recipe + field values + schedule
|
|
2558
|
+
// into a cron prompt that uses the user's authenticated Claude Desktop
|
|
2559
|
+
// connectors (Google Drive, Gmail, Outlook, etc.) to pull records and
|
|
2560
|
+
// calls brain_ingest_folder to commit them.
|
|
2561
|
+
app.get('/api/brain/connectors', async (_req, res) => {
|
|
2562
|
+
try {
|
|
2563
|
+
const { getClaudeIntegrations } = await import('../agent/mcp-bridge.js');
|
|
2564
|
+
const { RECIPES } = await import('../brain/connector-recipes.js');
|
|
2565
|
+
const integrations = getClaudeIntegrations();
|
|
2566
|
+
// A connector is "useful" for feeds only if it has surfaced some
|
|
2567
|
+
// substantive tools (not just the stubbed authenticate/complete pair).
|
|
2568
|
+
const meaningful = integrations.map((i) => ({
|
|
2569
|
+
...i,
|
|
2570
|
+
hasFeedReadyTools: i.tools.some((t) => !/^(authenticate|complete_authentication)$/.test(t)),
|
|
2571
|
+
}));
|
|
2572
|
+
res.json({
|
|
2573
|
+
integrations: meaningful,
|
|
2574
|
+
recipes: RECIPES.map((r) => ({
|
|
2575
|
+
id: r.id, label: r.label, description: r.description, icon: r.icon,
|
|
2576
|
+
integration: r.integration, fields: r.fields,
|
|
2577
|
+
defaultSchedule: r.defaultSchedule, tier: r.tier,
|
|
2578
|
+
})),
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
catch (err) {
|
|
2582
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2583
|
+
}
|
|
2584
|
+
});
|
|
2585
|
+
app.get('/api/brain/feeds', (_req, res) => {
|
|
2586
|
+
try {
|
|
2587
|
+
const { parsed, jobs } = readCronFileAt(CRON_FILE);
|
|
2588
|
+
void parsed;
|
|
2589
|
+
const feeds = jobs
|
|
2590
|
+
.filter((j) => String(j.managed ?? '') === 'connector-feed')
|
|
2591
|
+
.map((j) => ({
|
|
2592
|
+
name: String(j.name ?? ''),
|
|
2593
|
+
schedule: String(j.schedule ?? ''),
|
|
2594
|
+
enabled: j.enabled !== false,
|
|
2595
|
+
tier: Number(j.tier ?? 1),
|
|
2596
|
+
recipeId: String(j.recipe_id ?? ''),
|
|
2597
|
+
slug: String(j.feed_slug ?? ''),
|
|
2598
|
+
targetFolder: String(j.target_folder ?? ''),
|
|
2599
|
+
fields: j.recipe_fields ?? {},
|
|
2600
|
+
prompt: String(j.prompt ?? ''),
|
|
2601
|
+
}));
|
|
2602
|
+
res.json({ feeds });
|
|
2603
|
+
}
|
|
2604
|
+
catch (err) {
|
|
2605
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2606
|
+
}
|
|
2607
|
+
});
|
|
2608
|
+
app.post('/api/brain/feeds', async (req, res) => {
|
|
2609
|
+
try {
|
|
2610
|
+
const body = (req.body ?? {});
|
|
2611
|
+
if (!body.recipeId) {
|
|
2612
|
+
res.status(400).json({ error: 'recipeId is required' });
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
const { recipeById, buildFeedSpec, missingFields } = await import('../brain/connector-recipes.js');
|
|
2616
|
+
const recipe = recipeById(body.recipeId);
|
|
2617
|
+
if (!recipe) {
|
|
2618
|
+
res.status(400).json({ error: `unknown recipeId: ${body.recipeId}` });
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
const values = body.values ?? {};
|
|
2622
|
+
const missing = missingFields(recipe, values);
|
|
2623
|
+
if (missing.length) {
|
|
2624
|
+
res.status(400).json({ error: `missing required field(s): ${missing.join(', ')}` });
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
const schedule = (body.schedule || recipe.defaultSchedule).trim();
|
|
2628
|
+
if (!cron.validate(schedule)) {
|
|
2629
|
+
res.status(400).json({ error: `invalid cron expression: ${schedule}` });
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
const spec = buildFeedSpec(recipe, values);
|
|
2633
|
+
const { parsed, jobs } = readCronFileAt(CRON_FILE);
|
|
2634
|
+
if (jobs.find((j) => String(j.name ?? '').toLowerCase() === spec.jobName.toLowerCase())) {
|
|
2635
|
+
res.status(409).json({ error: `feed "${spec.jobName}" already exists` });
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
jobs.push({
|
|
2639
|
+
name: spec.jobName,
|
|
2640
|
+
schedule,
|
|
2641
|
+
prompt: spec.prompt,
|
|
2642
|
+
tier: recipe.tier,
|
|
2643
|
+
enabled: true,
|
|
2644
|
+
managed: 'connector-feed',
|
|
2645
|
+
recipe_id: recipe.id,
|
|
2646
|
+
feed_slug: spec.slug,
|
|
2647
|
+
target_folder: spec.targetFolder,
|
|
2648
|
+
recipe_fields: values,
|
|
2649
|
+
});
|
|
2650
|
+
writeCronFileAt(CRON_FILE, parsed, jobs);
|
|
2651
|
+
// Register the source row so the feed shows up alongside other sources
|
|
2652
|
+
// and ingestion_runs get a coherent per-feed audit trail.
|
|
2653
|
+
try {
|
|
2654
|
+
const { upsertSource } = await import('../brain/source-registry.js');
|
|
2655
|
+
await upsertSource({
|
|
2656
|
+
slug: spec.slug,
|
|
2657
|
+
kind: 'seed',
|
|
2658
|
+
adapter: 'markdown',
|
|
2659
|
+
configJson: JSON.stringify({
|
|
2660
|
+
managed: 'connector-feed',
|
|
2661
|
+
recipeId: recipe.id,
|
|
2662
|
+
fields: values,
|
|
2663
|
+
inputPath: path.join(VAULT_DIR, spec.targetFolder),
|
|
2664
|
+
}),
|
|
2665
|
+
targetFolder: spec.targetFolder,
|
|
2666
|
+
intelligence: 'auto',
|
|
2667
|
+
enabled: true,
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
catch (err) {
|
|
2671
|
+
// Non-fatal: the cron can still run even if the source registry write
|
|
2672
|
+
// fails (brain_ingest_folder will upsert on first run).
|
|
2673
|
+
console.warn('[feeds] upsertSource failed, continuing:', err);
|
|
2674
|
+
}
|
|
2675
|
+
res.json({
|
|
2676
|
+
ok: true,
|
|
2677
|
+
feed: {
|
|
2678
|
+
name: spec.jobName,
|
|
2679
|
+
schedule,
|
|
2680
|
+
slug: spec.slug,
|
|
2681
|
+
targetFolder: spec.targetFolder,
|
|
2682
|
+
recipeId: recipe.id,
|
|
2683
|
+
},
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
catch (err) {
|
|
2687
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2688
|
+
}
|
|
2689
|
+
});
|
|
2690
|
+
app.post('/api/brain/feeds/:name/run', (req, res) => {
|
|
2691
|
+
// Delegate to the same spawn-and-detach path /api/cron/run uses so we
|
|
2692
|
+
// get the same broadcast events and exit handling.
|
|
2693
|
+
const jobName = req.params.name;
|
|
2694
|
+
try {
|
|
2695
|
+
const { parsed, jobs } = readCronFileAt(CRON_FILE);
|
|
2696
|
+
void parsed;
|
|
2697
|
+
const job = jobs.find((j) => String(j.name ?? '') === jobName);
|
|
2698
|
+
if (!job || String(job.managed ?? '') !== 'connector-feed') {
|
|
2699
|
+
res.status(404).json({ error: `feed "${jobName}" not found` });
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
const child = spawn('node', [DIST_ENTRY, 'cron', 'run', jobName], {
|
|
2703
|
+
detached: true,
|
|
2704
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2705
|
+
cwd: BASE_DIR,
|
|
2706
|
+
env: { ...process.env, CLEMENTINE_HOME: BASE_DIR },
|
|
2707
|
+
});
|
|
2708
|
+
child.on('exit', (code) => {
|
|
2709
|
+
broadcastEvent({ type: 'cron_complete', data: { job: jobName, code } });
|
|
2710
|
+
});
|
|
2711
|
+
child.unref();
|
|
2712
|
+
broadcastEvent({ type: 'cron_triggered', data: { job: jobName } });
|
|
2713
|
+
res.json({ ok: true, message: `triggered feed: ${jobName}` });
|
|
2714
|
+
}
|
|
2715
|
+
catch (err) {
|
|
2716
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2717
|
+
}
|
|
2718
|
+
});
|
|
2719
|
+
app.delete('/api/brain/feeds/:name', async (req, res) => {
|
|
2720
|
+
try {
|
|
2721
|
+
const jobName = req.params.name;
|
|
2722
|
+
const { parsed, jobs } = readCronFileAt(CRON_FILE);
|
|
2723
|
+
const idx = jobs.findIndex((j) => String(j.name ?? '') === jobName);
|
|
2724
|
+
if (idx === -1 || String(jobs[idx].managed ?? '') !== 'connector-feed') {
|
|
2725
|
+
res.status(404).json({ error: `feed "${jobName}" not found` });
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
const slug = String(jobs[idx].feed_slug ?? '');
|
|
2729
|
+
jobs.splice(idx, 1);
|
|
2730
|
+
writeCronFileAt(CRON_FILE, parsed, jobs);
|
|
2731
|
+
// Remove the source-registry row too (the 04-Ingest folder is left
|
|
2732
|
+
// alone so already-ingested records survive).
|
|
2733
|
+
if (slug) {
|
|
2734
|
+
try {
|
|
2735
|
+
const { deleteSource } = await import('../brain/source-registry.js');
|
|
2736
|
+
await deleteSource(slug);
|
|
2737
|
+
}
|
|
2738
|
+
catch (err) {
|
|
2739
|
+
console.warn('[feeds] deleteSource failed:', err);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
res.json({ ok: true, message: `deleted feed: ${jobName}` });
|
|
2743
|
+
}
|
|
2744
|
+
catch (err) {
|
|
2745
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2746
|
+
}
|
|
2747
|
+
});
|
|
2552
2748
|
app.get('/api/brain/sources', async (_req, res) => {
|
|
2553
2749
|
try {
|
|
2554
2750
|
const { listSources } = await import('../brain/source-registry.js');
|
|
@@ -10004,6 +10200,33 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
10004
10200
|
|
|
10005
10201
|
<!-- Sources -->
|
|
10006
10202
|
<div class="tab-pane" id="tab-intelligence-sources">
|
|
10203
|
+
|
|
10204
|
+
<!-- ═══ Auto-seed feeds (Claude Desktop connectors → cron → brain) ═══ -->
|
|
10205
|
+
<div class="card" style="padding:16px;margin-bottom:16px">
|
|
10206
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
|
10207
|
+
<div style="font-weight:600">Auto-seed feeds</div>
|
|
10208
|
+
<button class="btn-primary" onclick="brainOpenFeedWizard()">+ Add feed</button>
|
|
10209
|
+
</div>
|
|
10210
|
+
<div style="color:var(--muted);font-size:13px;margin-bottom:12px">
|
|
10211
|
+
One-click scheduled feeds that use your authenticated Claude Desktop connectors (Google Drive, Outlook, Gmail, Slack…) to pull records and commit them to the brain. No API keys required.
|
|
10212
|
+
</div>
|
|
10213
|
+
<div id="brain-feeds-connectors" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px"></div>
|
|
10214
|
+
<div id="brain-feeds-list"></div>
|
|
10215
|
+
</div>
|
|
10216
|
+
|
|
10217
|
+
<!-- ═══ Auto-seed feed wizard (hidden by default) ═══ -->
|
|
10218
|
+
<div id="brain-feed-wizard" class="card" style="display:none;padding:16px;margin-bottom:16px">
|
|
10219
|
+
<div style="font-weight:600;margin-bottom:4px">Add auto-seed feed</div>
|
|
10220
|
+
<div id="brain-feed-wizard-breadcrumbs" style="color:var(--muted);font-size:12px;margin-bottom:12px"></div>
|
|
10221
|
+
<div id="brain-feed-wizard-step"></div>
|
|
10222
|
+
<div style="display:flex;gap:8px;margin-top:14px">
|
|
10223
|
+
<button class="btn" onclick="brainFeedWizardBack()" id="brain-feed-wizard-back">← Back</button>
|
|
10224
|
+
<button class="btn-primary" onclick="brainFeedWizardNext()" id="brain-feed-wizard-next">Next →</button>
|
|
10225
|
+
<button class="btn" onclick="brainCloseFeedWizard()">Cancel</button>
|
|
10226
|
+
<div id="brain-feed-wizard-status" style="margin-left:auto;font-size:13px"></div>
|
|
10227
|
+
</div>
|
|
10228
|
+
</div>
|
|
10229
|
+
|
|
10007
10230
|
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
|
10008
10231
|
<button class="btn-primary" onclick="brainShowPollForm()">+ Scheduled REST poll</button>
|
|
10009
10232
|
<button class="btn-primary" onclick="brainShowWebhookForm()">+ Inbound webhook</button>
|
|
@@ -10317,6 +10540,259 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
10317
10540
|
'</div>';
|
|
10318
10541
|
}
|
|
10319
10542
|
|
|
10543
|
+
// ── Auto-seed Feed wizard ───────────────────────────────────────
|
|
10544
|
+
let brainFeedWizardState = null; // { step, catalog, pick, values, schedule }
|
|
10545
|
+
|
|
10546
|
+
function brainScheduleChips() {
|
|
10547
|
+
return [
|
|
10548
|
+
{ key: 'daily-7', label: 'Daily 7am', cron: '0 7 * * *' },
|
|
10549
|
+
{ key: 'daily-9', label: 'Daily 9am', cron: '0 9 * * *' },
|
|
10550
|
+
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
|
|
10551
|
+
{ key: 'every-4', label: 'Every 4h', cron: '0 */4 * * *' },
|
|
10552
|
+
{ key: 'weekly', label: 'Weekly Mon 8am', cron: '0 8 * * 1' },
|
|
10553
|
+
];
|
|
10554
|
+
}
|
|
10555
|
+
|
|
10556
|
+
async function brainLoadFeedConnectors() {
|
|
10557
|
+
try {
|
|
10558
|
+
const resp = await apiFetch('/api/brain/connectors');
|
|
10559
|
+
const data = await resp.json();
|
|
10560
|
+
const el = document.getElementById('brain-feeds-connectors');
|
|
10561
|
+
if (!el) return data;
|
|
10562
|
+
if (!data.integrations || !data.integrations.length) {
|
|
10563
|
+
el.innerHTML = '<div style="color:var(--muted);font-size:13px">No Claude Desktop connectors detected yet. Open Claude Desktop → Connectors to sign into Google Drive, Outlook, Gmail, etc.</div>';
|
|
10564
|
+
return data;
|
|
10565
|
+
}
|
|
10566
|
+
el.innerHTML = data.integrations.map(function(i) {
|
|
10567
|
+
const ok = i.connected && i.hasFeedReadyTools;
|
|
10568
|
+
const color = ok ? '#2f7d32' : '#8a5a00';
|
|
10569
|
+
const bg = ok ? '#e8f5e9' : '#fff3cd';
|
|
10570
|
+
const dot = ok ? '✓' : '⚠';
|
|
10571
|
+
const label = ok ? i.label : i.label + ' (incomplete in Claude Desktop)';
|
|
10572
|
+
return '<span style="padding:3px 10px;border-radius:12px;background:' + bg + ';color:' + color + ';font-size:12px;font-weight:500">' + dot + ' ' + escapeHtml(label) + '</span>';
|
|
10573
|
+
}).join('');
|
|
10574
|
+
return data;
|
|
10575
|
+
} catch (err) {
|
|
10576
|
+
console.error('brainLoadFeedConnectors failed', err);
|
|
10577
|
+
return { integrations: [], recipes: [] };
|
|
10578
|
+
}
|
|
10579
|
+
}
|
|
10580
|
+
|
|
10581
|
+
async function brainLoadFeeds() {
|
|
10582
|
+
try {
|
|
10583
|
+
const resp = await apiFetch('/api/brain/feeds');
|
|
10584
|
+
const data = await resp.json();
|
|
10585
|
+
const el = document.getElementById('brain-feeds-list');
|
|
10586
|
+
if (!el) return;
|
|
10587
|
+
if (!data.feeds || !data.feeds.length) {
|
|
10588
|
+
el.innerHTML = '<div style="color:var(--muted);font-size:13px;padding:8px 0">No feeds yet. Click <b>+ Add feed</b> to wire one up.</div>';
|
|
10589
|
+
return;
|
|
10590
|
+
}
|
|
10591
|
+
el.innerHTML = data.feeds.map(function(f) {
|
|
10592
|
+
const fieldsLine = Object.keys(f.fields || {}).length
|
|
10593
|
+
? Object.entries(f.fields).map(function(kv) { return '<code style="font-size:11px">' + escapeHtml(kv[0]) + '=' + escapeHtml(String(kv[1])) + '</code>'; }).join(' · ')
|
|
10594
|
+
: '<span style="color:var(--muted)">no fields</span>';
|
|
10595
|
+
return '<div class="card" style="padding:10px 12px;margin-bottom:8px;display:flex;align-items:center;gap:12px">' +
|
|
10596
|
+
'<div style="flex:1">' +
|
|
10597
|
+
'<div style="font-weight:600">' + escapeHtml(f.name) + (f.enabled ? '' : ' <span style="color:#e66;font-weight:normal">(disabled)</span>') + '</div>' +
|
|
10598
|
+
'<div style="font-size:12px;color:var(--muted)">' +
|
|
10599
|
+
'Recipe: <code>' + escapeHtml(f.recipeId) + '</code> · Schedule: <code>' + escapeHtml(f.schedule) + '</code> · Target: <code>' + escapeHtml(f.targetFolder) + '</code>' +
|
|
10600
|
+
'</div>' +
|
|
10601
|
+
'<div style="font-size:12px;margin-top:4px">' + fieldsLine + '</div>' +
|
|
10602
|
+
'</div>' +
|
|
10603
|
+
'<button class="btn-primary" onclick="brainRunFeed(\\'' + f.name.replace(/"/g, '') + '\\')">Run now</button> ' +
|
|
10604
|
+
'<button class="btn" onclick="brainDeleteFeed(\\'' + f.name.replace(/"/g, '') + '\\')">🗑</button>' +
|
|
10605
|
+
'</div>';
|
|
10606
|
+
}).join('');
|
|
10607
|
+
} catch (err) {
|
|
10608
|
+
console.error('brainLoadFeeds failed', err);
|
|
10609
|
+
}
|
|
10610
|
+
}
|
|
10611
|
+
|
|
10612
|
+
async function brainOpenFeedWizard() {
|
|
10613
|
+
const data = await brainLoadFeedConnectors();
|
|
10614
|
+
const connected = (data.integrations || []).filter(function(i) { return i.connected && i.hasFeedReadyTools; });
|
|
10615
|
+
brainFeedWizardState = {
|
|
10616
|
+
step: 0,
|
|
10617
|
+
catalog: data,
|
|
10618
|
+
connected,
|
|
10619
|
+
pick: null,
|
|
10620
|
+
recipe: null,
|
|
10621
|
+
values: {},
|
|
10622
|
+
schedule: '',
|
|
10623
|
+
};
|
|
10624
|
+
document.getElementById('brain-feed-wizard').style.display = '';
|
|
10625
|
+
brainFeedWizardRender();
|
|
10626
|
+
}
|
|
10627
|
+
|
|
10628
|
+
function brainCloseFeedWizard() {
|
|
10629
|
+
brainFeedWizardState = null;
|
|
10630
|
+
document.getElementById('brain-feed-wizard').style.display = 'none';
|
|
10631
|
+
}
|
|
10632
|
+
|
|
10633
|
+
function brainFeedWizardBack() {
|
|
10634
|
+
if (!brainFeedWizardState) return;
|
|
10635
|
+
if (brainFeedWizardState.step === 0) { brainCloseFeedWizard(); return; }
|
|
10636
|
+
brainFeedWizardState.step -= 1;
|
|
10637
|
+
brainFeedWizardRender();
|
|
10638
|
+
}
|
|
10639
|
+
|
|
10640
|
+
function brainFeedWizardNext() {
|
|
10641
|
+
if (!brainFeedWizardState) return;
|
|
10642
|
+
const s = brainFeedWizardState;
|
|
10643
|
+
if (s.step === 0) {
|
|
10644
|
+
if (!s.pick) { document.getElementById('brain-feed-wizard-status').innerHTML = '<span style="color:#e66">Pick a connector.</span>'; return; }
|
|
10645
|
+
s.step = 1;
|
|
10646
|
+
} else if (s.step === 1) {
|
|
10647
|
+
if (!s.recipe) { document.getElementById('brain-feed-wizard-status').innerHTML = '<span style="color:#e66">Pick a recipe.</span>'; return; }
|
|
10648
|
+
s.values = {};
|
|
10649
|
+
for (const f of (s.recipe.fields || [])) {
|
|
10650
|
+
if (f.defaultValue) s.values[f.key] = f.defaultValue;
|
|
10651
|
+
}
|
|
10652
|
+
s.schedule = s.recipe.defaultSchedule;
|
|
10653
|
+
s.step = 2;
|
|
10654
|
+
} else if (s.step === 2) {
|
|
10655
|
+
for (const key of Object.keys({})) void key;
|
|
10656
|
+
const inputs = document.querySelectorAll('#brain-feed-wizard-step [data-field]');
|
|
10657
|
+
inputs.forEach(function(inp) { s.values[inp.dataset.field] = inp.value; });
|
|
10658
|
+
const missing = (s.recipe.fields || []).filter(function(f) { return f.required && !(s.values[f.key] || '').trim(); });
|
|
10659
|
+
if (missing.length) { document.getElementById('brain-feed-wizard-status').innerHTML = '<span style="color:#e66">Required: ' + missing.map(function(f) { return f.label; }).join(', ') + '</span>'; return; }
|
|
10660
|
+
s.step = 3;
|
|
10661
|
+
} else if (s.step === 3) {
|
|
10662
|
+
brainFeedWizardSubmit();
|
|
10663
|
+
return;
|
|
10664
|
+
}
|
|
10665
|
+
document.getElementById('brain-feed-wizard-status').innerHTML = '';
|
|
10666
|
+
brainFeedWizardRender();
|
|
10667
|
+
}
|
|
10668
|
+
|
|
10669
|
+
function brainFeedWizardRender() {
|
|
10670
|
+
if (!brainFeedWizardState) return;
|
|
10671
|
+
const s = brainFeedWizardState;
|
|
10672
|
+
const crumbs = ['Connector','Recipe','Fields','Schedule'].map(function(c, i) {
|
|
10673
|
+
return (i === s.step) ? '<b>' + c + '</b>' : c;
|
|
10674
|
+
}).join(' → ');
|
|
10675
|
+
document.getElementById('brain-feed-wizard-breadcrumbs').innerHTML = crumbs;
|
|
10676
|
+
const backBtn = document.getElementById('brain-feed-wizard-back');
|
|
10677
|
+
backBtn.textContent = '← Back';
|
|
10678
|
+
backBtn.style.display = s.step === 0 ? 'none' : '';
|
|
10679
|
+
document.getElementById('brain-feed-wizard-next').textContent = s.step === 3 ? 'Create feed' : 'Next →';
|
|
10680
|
+
|
|
10681
|
+
let html = '';
|
|
10682
|
+
if (s.step === 0) {
|
|
10683
|
+
if (!s.connected.length) {
|
|
10684
|
+
html = '<div style="color:#8a5a00">No connectors have feed-ready tools. Open Claude Desktop → Connectors and sign into Google Drive, Outlook, Gmail, or Slack first.</div>';
|
|
10685
|
+
} else {
|
|
10686
|
+
html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px">' +
|
|
10687
|
+
s.connected.map(function(i) {
|
|
10688
|
+
const picked = s.pick && s.pick.name === i.name;
|
|
10689
|
+
return '<button class="btn' + (picked ? '-primary' : '') + '" onclick="brainFeedWizardPickConnector(\\'' + i.name + '\\')" style="padding:12px;text-align:left">' +
|
|
10690
|
+
'<div style="font-weight:600">' + escapeHtml(i.label) + '</div>' +
|
|
10691
|
+
'<div style="font-size:11px;color:var(--muted)">' + (i.tools || []).length + ' tools</div>' +
|
|
10692
|
+
'</button>';
|
|
10693
|
+
}).join('') + '</div>';
|
|
10694
|
+
}
|
|
10695
|
+
} else if (s.step === 1) {
|
|
10696
|
+
const recipes = (s.catalog.recipes || []).filter(function(r) { return r.integration === s.pick.name; });
|
|
10697
|
+
if (!recipes.length) {
|
|
10698
|
+
html = '<div style="color:var(--muted)">No recipes for this connector yet.</div>';
|
|
10699
|
+
} else {
|
|
10700
|
+
html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:8px">' +
|
|
10701
|
+
recipes.map(function(r) {
|
|
10702
|
+
const picked = s.recipe && s.recipe.id === r.id;
|
|
10703
|
+
return '<button class="btn' + (picked ? '-primary' : '') + '" onclick="brainFeedWizardPickRecipe(\\'' + r.id + '\\')" style="padding:12px;text-align:left">' +
|
|
10704
|
+
'<div style="font-weight:600">' + escapeHtml((r.icon || '') + ' ' + r.label) + '</div>' +
|
|
10705
|
+
'<div style="font-size:12px;margin-top:4px;color:var(--muted);white-space:normal;line-height:1.4">' + escapeHtml(r.description) + '</div>' +
|
|
10706
|
+
'</button>';
|
|
10707
|
+
}).join('') + '</div>';
|
|
10708
|
+
}
|
|
10709
|
+
} else if (s.step === 2) {
|
|
10710
|
+
const fields = s.recipe.fields || [];
|
|
10711
|
+
if (!fields.length) {
|
|
10712
|
+
html = '<div style="color:var(--muted)">This recipe has no fields. Click Next to pick a schedule.</div>';
|
|
10713
|
+
} else {
|
|
10714
|
+
html = '<div style="display:grid;grid-template-columns:180px 1fr;gap:10px;align-items:center">' +
|
|
10715
|
+
fields.map(function(f) {
|
|
10716
|
+
const val = s.values[f.key] != null ? s.values[f.key] : (f.defaultValue || '');
|
|
10717
|
+
const help = f.help ? '<div style="font-size:11px;color:var(--muted);margin-top:2px">' + escapeHtml(f.help) + '</div>' : '';
|
|
10718
|
+
return '<label style="font-weight:500">' + escapeHtml(f.label) + (f.required ? ' <span style="color:#e66">*</span>' : '') + '</label>' +
|
|
10719
|
+
'<div><input type="text" data-field="' + f.key + '" value="' + escapeHtml(val) + '" placeholder="' + escapeHtml(f.placeholder || '') + '" style="width:100%">' + help + '</div>';
|
|
10720
|
+
}).join('') + '</div>';
|
|
10721
|
+
}
|
|
10722
|
+
} else if (s.step === 3) {
|
|
10723
|
+
const chips = brainScheduleChips();
|
|
10724
|
+
html = '<div style="margin-bottom:10px">Choose how often this feed runs:</div>' +
|
|
10725
|
+
'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">' +
|
|
10726
|
+
chips.map(function(c) {
|
|
10727
|
+
const picked = s.schedule === c.cron;
|
|
10728
|
+
return '<button class="btn' + (picked ? '-primary' : '') + '" onclick="brainFeedWizardPickSchedule(\\'' + c.cron + '\\')" style="padding:6px 12px">' + escapeHtml(c.label) + '</button>';
|
|
10729
|
+
}).join('') +
|
|
10730
|
+
'</div>' +
|
|
10731
|
+
'<div style="display:flex;gap:8px;align-items:center;font-size:13px">' +
|
|
10732
|
+
'<label style="min-width:100px">Custom cron:</label>' +
|
|
10733
|
+
'<input type="text" id="brain-feed-schedule-custom" value="' + escapeHtml(s.schedule) + '" style="flex:1" oninput="brainFeedWizardSetCustomSchedule(this.value)">' +
|
|
10734
|
+
'</div>';
|
|
10735
|
+
}
|
|
10736
|
+
document.getElementById('brain-feed-wizard-step').innerHTML = html;
|
|
10737
|
+
}
|
|
10738
|
+
|
|
10739
|
+
function brainFeedWizardPickConnector(name) {
|
|
10740
|
+
const i = (brainFeedWizardState.catalog.integrations || []).find(function(x) { return x.name === name; });
|
|
10741
|
+
brainFeedWizardState.pick = i;
|
|
10742
|
+
brainFeedWizardState.recipe = null;
|
|
10743
|
+
brainFeedWizardRender();
|
|
10744
|
+
}
|
|
10745
|
+
|
|
10746
|
+
function brainFeedWizardPickRecipe(id) {
|
|
10747
|
+
const r = (brainFeedWizardState.catalog.recipes || []).find(function(x) { return x.id === id; });
|
|
10748
|
+
brainFeedWizardState.recipe = r;
|
|
10749
|
+
brainFeedWizardRender();
|
|
10750
|
+
}
|
|
10751
|
+
|
|
10752
|
+
function brainFeedWizardPickSchedule(cron) {
|
|
10753
|
+
brainFeedWizardState.schedule = cron;
|
|
10754
|
+
brainFeedWizardRender();
|
|
10755
|
+
}
|
|
10756
|
+
|
|
10757
|
+
function brainFeedWizardSetCustomSchedule(val) {
|
|
10758
|
+
if (brainFeedWizardState) brainFeedWizardState.schedule = val;
|
|
10759
|
+
}
|
|
10760
|
+
|
|
10761
|
+
async function brainFeedWizardSubmit() {
|
|
10762
|
+
const s = brainFeedWizardState;
|
|
10763
|
+
document.getElementById('brain-feed-wizard-status').innerHTML = 'Creating…';
|
|
10764
|
+
try {
|
|
10765
|
+
const resp = await apiFetch('/api/brain/feeds', {
|
|
10766
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
10767
|
+
body: JSON.stringify({ recipeId: s.recipe.id, values: s.values, schedule: s.schedule }),
|
|
10768
|
+
});
|
|
10769
|
+
const data = await resp.json();
|
|
10770
|
+
if (!resp.ok) {
|
|
10771
|
+
document.getElementById('brain-feed-wizard-status').innerHTML = '<span style="color:#e66">' + escapeHtml(data.error || 'save failed') + '</span>';
|
|
10772
|
+
return;
|
|
10773
|
+
}
|
|
10774
|
+
brainCloseFeedWizard();
|
|
10775
|
+
brainLoadFeeds();
|
|
10776
|
+
} catch (err) {
|
|
10777
|
+
document.getElementById('brain-feed-wizard-status').innerHTML = '<span style="color:#e66">' + escapeHtml(String(err)) + '</span>';
|
|
10778
|
+
}
|
|
10779
|
+
}
|
|
10780
|
+
|
|
10781
|
+
async function brainRunFeed(name) {
|
|
10782
|
+
const resp = await apiFetch('/api/brain/feeds/' + encodeURIComponent(name) + '/run', { method: 'POST' });
|
|
10783
|
+
const data = await resp.json();
|
|
10784
|
+
if (!resp.ok) { alert('Run failed: ' + (data.error || 'unknown')); return; }
|
|
10785
|
+
alert('Triggered — watch the Ingestion Runs tab in ~30–90s for results.');
|
|
10786
|
+
}
|
|
10787
|
+
|
|
10788
|
+
async function brainDeleteFeed(name) {
|
|
10789
|
+
if (!confirm('Delete feed "' + name + '"? (The 04-Ingest folder is kept so historical records survive.)')) return;
|
|
10790
|
+
const resp = await apiFetch('/api/brain/feeds/' + encodeURIComponent(name), { method: 'DELETE' });
|
|
10791
|
+
const data = await resp.json();
|
|
10792
|
+
if (!resp.ok) { alert('Delete failed: ' + (data.error || 'unknown')); return; }
|
|
10793
|
+
brainLoadFeeds();
|
|
10794
|
+
}
|
|
10795
|
+
|
|
10320
10796
|
async function brainLoadSources() {
|
|
10321
10797
|
const resp = await apiFetch('/api/brain/sources');
|
|
10322
10798
|
const data = await resp.json();
|
|
@@ -10607,7 +11083,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
10607
11083
|
const origSwitch = window.switchTab;
|
|
10608
11084
|
window.switchTab = function(page, tab) {
|
|
10609
11085
|
origSwitch(page, tab);
|
|
10610
|
-
if (page === 'intelligence' && tab === 'sources') brainLoadSources();
|
|
11086
|
+
if (page === 'intelligence' && tab === 'sources') { brainLoadSources(); brainLoadFeedConnectors(); brainLoadFeeds(); }
|
|
10611
11087
|
if (page === 'intelligence' && tab === 'runs') brainLoadRuns();
|
|
10612
11088
|
};
|
|
10613
11089
|
})();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Brain MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Tools the agent uses to feed the brain's ingestion pipeline from cron jobs.
|
|
5
|
+
* Primarily used by Connector Feeds (src/brain/connector-recipes.ts) — each
|
|
6
|
+
* feed's cron prompt ends with a brain_ingest_folder call that writes fetched
|
|
7
|
+
* records to 04-Ingest/<slug>/ and runs distillation.
|
|
8
|
+
*/
|
|
9
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
export declare function registerBrainTools(server: McpServer): void;
|
|
11
|
+
//# sourceMappingURL=brain-tools.d.ts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Brain MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Tools the agent uses to feed the brain's ingestion pipeline from cron jobs.
|
|
5
|
+
* Primarily used by Connector Feeds (src/brain/connector-recipes.ts) — each
|
|
6
|
+
* feed's cron prompt ends with a brain_ingest_folder call that writes fetched
|
|
7
|
+
* records to 04-Ingest/<slug>/ and runs distillation.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { VAULT_DIR, logger, textResult } from './shared.js';
|
|
13
|
+
/** Slugify a record title for the filename — URL-safe, short, collision-resistant with externalId. */
|
|
14
|
+
function filenameFor(title, externalId) {
|
|
15
|
+
const base = String(title || externalId || 'record')
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
18
|
+
.replace(/^-+|-+$/g, '')
|
|
19
|
+
.slice(0, 60) || 'record';
|
|
20
|
+
const idPart = String(externalId || '')
|
|
21
|
+
.replace(/[^a-zA-Z0-9]+/g, '')
|
|
22
|
+
.slice(0, 16) || 'x';
|
|
23
|
+
return `${base}-${idPart}.md`;
|
|
24
|
+
}
|
|
25
|
+
function formatFrontmatter(record, slug) {
|
|
26
|
+
const frontmatter = {
|
|
27
|
+
source: slug,
|
|
28
|
+
externalId: record.externalId,
|
|
29
|
+
title: record.title,
|
|
30
|
+
fetchedAt: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
if (record.metadata && typeof record.metadata === 'object') {
|
|
33
|
+
for (const [k, v] of Object.entries(record.metadata)) {
|
|
34
|
+
if (v != null)
|
|
35
|
+
frontmatter[k] = v;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const lines = ['---'];
|
|
39
|
+
for (const [k, v] of Object.entries(frontmatter)) {
|
|
40
|
+
if (typeof v === 'string') {
|
|
41
|
+
// Quote strings containing colons or special chars
|
|
42
|
+
if (/[:#\[\]\n]/.test(v))
|
|
43
|
+
lines.push(`${k}: ${JSON.stringify(v)}`);
|
|
44
|
+
else
|
|
45
|
+
lines.push(`${k}: ${v}`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
lines.push(`${k}: ${JSON.stringify(v)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
lines.push('---');
|
|
52
|
+
return lines.join('\n') + '\n\n';
|
|
53
|
+
}
|
|
54
|
+
export function registerBrainTools(server) {
|
|
55
|
+
server.tool('brain_ingest_folder', 'Ingest a batch of records into the brain under a named slug. Writes each record as a markdown file in 04-Ingest/<slug>/ with frontmatter, then runs the distillation pipeline (chunking, LLM summarization, vault note write, knowledge graph write). Use at the end of Connector Feed cron jobs. Safe to re-run — existing files with matching content hashes are deduped by the pipeline.', {
|
|
56
|
+
slug: z.string().describe('Feed slug (matches 04-Ingest/<slug> folder). Lowercase, hyphen-separated.'),
|
|
57
|
+
records: z.array(z.object({
|
|
58
|
+
title: z.string().describe('Human-readable title for this record.'),
|
|
59
|
+
externalId: z.string().describe('Stable provider id so re-runs dedup (e.g. Gmail message id, Drive file id).'),
|
|
60
|
+
content: z.string().describe('The full text content of the record. Will be chunked and distilled.'),
|
|
61
|
+
metadata: z.record(z.string(), z.unknown()).optional().describe('Any key/value fields to preserve in frontmatter (url, modifiedAt, author).'),
|
|
62
|
+
})).describe('The records to ingest.'),
|
|
63
|
+
}, async ({ slug, records }) => {
|
|
64
|
+
const safeSlug = String(slug).toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '');
|
|
65
|
+
if (!safeSlug)
|
|
66
|
+
return textResult('brain_ingest_folder: slug is required');
|
|
67
|
+
if (!Array.isArray(records) || records.length === 0) {
|
|
68
|
+
return textResult(`brain_ingest_folder: no records to ingest for slug "${safeSlug}".`);
|
|
69
|
+
}
|
|
70
|
+
const targetFolder = path.join(VAULT_DIR, '04-Ingest', safeSlug);
|
|
71
|
+
mkdirSync(targetFolder, { recursive: true });
|
|
72
|
+
// Write each record to a markdown file
|
|
73
|
+
let writtenCount = 0;
|
|
74
|
+
let skippedExisting = 0;
|
|
75
|
+
for (const r of records) {
|
|
76
|
+
if (!r.content || !r.content.trim())
|
|
77
|
+
continue;
|
|
78
|
+
const fname = filenameFor(r.title, r.externalId);
|
|
79
|
+
const fullPath = path.join(targetFolder, fname);
|
|
80
|
+
const body = formatFrontmatter(r, safeSlug) + r.content;
|
|
81
|
+
// Idempotency: if a file with the same externalId already exists, overwrite
|
|
82
|
+
// (the distillation pipeline does its own content-hash dedup).
|
|
83
|
+
const preExisting = existsSync(fullPath);
|
|
84
|
+
try {
|
|
85
|
+
writeFileSync(fullPath, body, 'utf-8');
|
|
86
|
+
if (preExisting)
|
|
87
|
+
skippedExisting += 1;
|
|
88
|
+
else
|
|
89
|
+
writtenCount += 1;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
logger.warn({ err, fullPath }, 'brain_ingest_folder: write failed for one record');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Run the distillation pipeline. Use a synthetic seed source so the
|
|
96
|
+
// ingestion framework can classify + distill + write back into the
|
|
97
|
+
// vault & graph with its existing dedup.
|
|
98
|
+
let ingestionSummary = '';
|
|
99
|
+
try {
|
|
100
|
+
const { upsertSource, getSource } = await import('../brain/source-registry.js');
|
|
101
|
+
const { runIngestion } = await import('../brain/ingestion-pipeline.js');
|
|
102
|
+
await upsertSource({
|
|
103
|
+
slug: safeSlug,
|
|
104
|
+
kind: 'seed',
|
|
105
|
+
adapter: 'markdown',
|
|
106
|
+
configJson: JSON.stringify({ inputPath: targetFolder, managed: 'connector-feed' }),
|
|
107
|
+
targetFolder: `04-Ingest/${safeSlug}`,
|
|
108
|
+
intelligence: 'auto',
|
|
109
|
+
enabled: true,
|
|
110
|
+
});
|
|
111
|
+
const source = await getSource(safeSlug);
|
|
112
|
+
if (!source)
|
|
113
|
+
throw new Error('failed to register source');
|
|
114
|
+
const result = await runIngestion({ source, inputPath: targetFolder });
|
|
115
|
+
ingestionSummary =
|
|
116
|
+
`Pipeline: ${result.recordsIn} in · ${result.recordsWritten} written · ${result.recordsSkipped} skipped · ${result.recordsFailed} failed`;
|
|
117
|
+
if (result.errors?.length) {
|
|
118
|
+
ingestionSummary += ` (first error: ${result.errors[0].error.slice(0, 100)})`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
123
|
+
logger.error({ err, slug: safeSlug }, 'brain_ingest_folder: ingestion pipeline failed');
|
|
124
|
+
return textResult(`brain_ingest_folder: wrote ${writtenCount} file(s) but ingestion failed: ${msg}`);
|
|
125
|
+
}
|
|
126
|
+
logger.info({ slug: safeSlug, writtenCount, skippedExisting, recordCount: records.length }, 'brain_ingest_folder complete');
|
|
127
|
+
return textResult(`Ingested into slug "${safeSlug}": ${writtenCount} new file(s), ${skippedExisting} updated in place. ${ingestionSummary}`);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=brain-tools.js.map
|
package/dist/tools/mcp-server.js
CHANGED
|
@@ -25,6 +25,7 @@ import { registerGoalTools } from './goal-tools.js';
|
|
|
25
25
|
import { registerTeamTools } from './team-tools.js';
|
|
26
26
|
import { registerSessionTools } from './session-tools.js';
|
|
27
27
|
import { registerArtifactTools } from './artifact-tools.js';
|
|
28
|
+
import { registerBrainTools } from './brain-tools.js';
|
|
28
29
|
// ── Server ──────────────────────────────────────────────────────────────
|
|
29
30
|
const serverName = (env['ASSISTANT_NAME'] ?? 'Clementine').toLowerCase() + '-tools';
|
|
30
31
|
const server = new McpServer({ name: serverName, version: '1.0.0' });
|
|
@@ -37,6 +38,7 @@ registerGoalTools(server);
|
|
|
37
38
|
registerTeamTools(server);
|
|
38
39
|
registerSessionTools(server);
|
|
39
40
|
registerArtifactTools(server);
|
|
41
|
+
registerBrainTools(server);
|
|
40
42
|
// ── Main ────────────────────────────────────────────────────────────────
|
|
41
43
|
async function main() {
|
|
42
44
|
// Initialize memory store and run full sync on startup
|