clementine-agent 1.18.144 → 1.18.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -463,31 +463,59 @@ function cmdStop() {
463
463
  catch { /* ignore */ }
464
464
  }
465
465
  }
466
+ /**
467
+ * 1.18.146 — Spawn the dashboard child detached + briefly verify it's
468
+ * listening on :3030. Used by both `restart` and `update` so the
469
+ * dashboard reliably comes back after a daemon refresh.
470
+ *
471
+ * The dashboard token rotates on each spawn — print the fresh URL so
472
+ * the user's old browser tab (which would silently 401 on the stale
473
+ * token) doesn't waste their time.
474
+ */
475
+ async function relaunchDashboardDetached() {
476
+ try {
477
+ const { spawn: spawnProc } = await import('node:child_process');
478
+ const child = spawnProc('node', [path.join(PACKAGE_ROOT, 'dist/cli/index.js'), 'dashboard'], { detached: true, stdio: 'ignore' });
479
+ child.unref();
480
+ // Brief liveness wait — give the child ~3s to bind before we
481
+ // print the URL. If it never binds, the URL still prints (user
482
+ // can retry) but we surface the failure in logs.
483
+ await new Promise(resolve => setTimeout(resolve, 3000));
484
+ let token = '';
485
+ try {
486
+ const tokenPath = path.join(BASE_DIR, '.dashboard-token');
487
+ if (existsSync(tokenPath))
488
+ token = readFileSync(tokenPath, 'utf-8').trim();
489
+ }
490
+ catch { /* token may not be ready yet */ }
491
+ if (token) {
492
+ console.log(` Dashboard relaunched: http://localhost:3030/?token=${token}`);
493
+ }
494
+ else {
495
+ console.log(' Dashboard relaunched (token not ready — check `clementine status`).');
496
+ }
497
+ }
498
+ catch {
499
+ console.log(' Could not relaunch dashboard — run: clementine dashboard');
500
+ }
501
+ }
466
502
  async function cmdRestart(options) {
467
503
  cmdStop();
468
- // Kill ALL dashboard processes (not just PID file — catches orphans)
469
- let dashboardWasRunning = false;
504
+ // Kill ALL dashboard processes (not just PID file — catches orphans).
505
+ // Restart implies "I want a fresh dashboard too" — always respawn,
506
+ // not just when the kill check found one. Closes the race where the
507
+ // dashboard had crashed (or been killed by an earlier `clementine
508
+ // update`) before restart ran, leaving the user with no dashboard.
470
509
  try {
471
510
  const { killExistingDashboards } = await import('./dashboard.js');
472
511
  const killed = killExistingDashboards();
473
512
  if (killed > 0) {
474
- dashboardWasRunning = true;
475
513
  console.log(` Stopped ${killed} dashboard process(es).`);
476
514
  }
477
515
  }
478
516
  catch { /* dashboard module may not be available */ }
479
517
  await cmdLaunch({ foreground: options.foreground });
480
- if (dashboardWasRunning) {
481
- try {
482
- const { spawn: spawnProc } = await import('node:child_process');
483
- const child = spawnProc('node', [path.join(PACKAGE_ROOT, 'dist/cli/index.js'), 'dashboard'], { detached: true, stdio: 'ignore' });
484
- child.unref();
485
- console.log(' Dashboard relaunched.');
486
- }
487
- catch {
488
- console.log(' Could not relaunch dashboard — run: clementine dashboard');
489
- }
490
- }
518
+ await relaunchDashboardDetached();
491
519
  }
492
520
  function cmdStatus() {
493
521
  const DIM = '\x1b[0;90m';
@@ -4262,10 +4290,13 @@ async function cmdUpdate(options) {
4262
4290
  }
4263
4291
  }
4264
4292
  catch { /* no dashboard running */ }
4265
- // Don't auto-relaunch dashboard during update it causes duplicate process issues.
4266
- // The daemon restart below will handle it, or user can run: clementine dashboard
4293
+ // 1.18.146 `dashboardWasRunning` was previously dropped on the
4294
+ // floor here (the comment said "the daemon restart below will handle
4295
+ // it" — but cmdRestart's own dashboard detection runs after this kill
4296
+ // so it sees zero and never respawns). Now we explicitly remember to
4297
+ // respawn at the end of update if the user had a dashboard up before.
4267
4298
  if (dashboardWasRunning) {
4268
- console.log(` Dashboard stopped. Relaunch with: ${DIM}clementine dashboard${RESET}`);
4299
+ console.log(` ${GREEN}OK${RESET} Dashboard will relaunch after daemon restart.`);
4269
4300
  }
4270
4301
  // 12. Write update sentinel so the daemon can report what happened
4271
4302
  let commitHash = '';
@@ -4399,6 +4430,14 @@ async function cmdUpdate(options) {
4399
4430
  }
4400
4431
  catch { /* .env read failed */ }
4401
4432
  }
4433
+ // 13.5. Respawn dashboard if it was running before the update.
4434
+ // 1.18.146 — closes the bug where `clementine update restart` left
4435
+ // users with no dashboard because the kill at step 11 above robbed
4436
+ // cmdRestart of its respawn signal. Now we own the respawn here
4437
+ // when we own the kill.
4438
+ if (dashboardWasRunning) {
4439
+ await relaunchDashboardDetached();
4440
+ }
4402
4441
  // 14. Show current version
4403
4442
  console.log();
4404
4443
  if (previousVersion !== 'unknown' && newVersion !== 'unknown' && previousVersion !== newVersion) {
@@ -31,5 +31,20 @@ export interface BrainIngestFolderResult {
31
31
  message: string;
32
32
  }
33
33
  export declare function ingestBrainRecords(slug: string, records: IngestRecordInput[]): Promise<BrainIngestFolderResult>;
34
+ export interface BrainSaveInput {
35
+ /** Either a URL (will be fetched) or raw text content. */
36
+ content: string;
37
+ /** Optional human-readable title. Inferred from <title> tag for HTML URLs. */
38
+ title?: string;
39
+ /** Logical bucket the record belongs to. Default: "chat-saves". */
40
+ slug?: string;
41
+ /** Stable id so repeat saves dedupe. Default: hash of content. */
42
+ externalId?: string;
43
+ /** Free-form tags carried in frontmatter for later filtering/recall. */
44
+ tags?: string[];
45
+ }
46
+ export declare function brainSave(input: BrainSaveInput): Promise<BrainIngestFolderResult & {
47
+ sourceType: 'url' | 'text';
48
+ }>;
34
49
  export declare function registerBrainTools(server: McpServer): void;
35
50
  //# sourceMappingURL=brain-tools.d.ts.map
@@ -125,7 +125,120 @@ export async function ingestBrainRecords(slug, records) {
125
125
  message,
126
126
  };
127
127
  }
128
+ // ── brain_save — one-shot ingestion from chat ─────────────────────────
129
+ //
130
+ // 1.18.145 — closes the chat-parity gap on the write side. The user
131
+ // can now say "save this article" or "ingest this URL" and the agent
132
+ // drives the same pipeline the dashboard's Seed tab uses, no manual
133
+ // record assembly required.
134
+ //
135
+ // Accepts either raw text or a URL. URLs are fetched + reasonably
136
+ // extracted (HTML→text via regex strip, JSON/markdown passthrough);
137
+ // for richer extraction the user should still use the dashboard's
138
+ // adapter pipeline (PDF/DOCX/CSV/etc. — they need parsers we don't
139
+ // want to import into every chat session).
140
+ const URL_LIKE = /^https?:\/\//i;
141
+ function looksLikeUrl(s) {
142
+ return URL_LIKE.test(s.trim());
143
+ }
144
+ async function fetchUrlText(url, timeoutMs = 20_000) {
145
+ const controller = new AbortController();
146
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
147
+ try {
148
+ const res = await fetch(url, {
149
+ signal: controller.signal,
150
+ headers: { 'User-Agent': 'Clementine/brain_save (compatible; +https://github.com/Natebreynolds/Clementine-AI-Assistant)' },
151
+ });
152
+ if (!res.ok)
153
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
154
+ const contentType = res.headers.get('content-type') ?? '';
155
+ const raw = await res.text();
156
+ if (/json/i.test(contentType)) {
157
+ return { text: raw, contentType };
158
+ }
159
+ if (/html/i.test(contentType) || /^\s*<!DOCTYPE|^\s*<html/i.test(raw)) {
160
+ // Crude HTML→text. Good enough for "save this article" — anything
161
+ // fancier (Readability, Mercury) is a dashboard concern.
162
+ const titleMatch = raw.match(/<title[^>]*>([^<]+)<\/title>/i);
163
+ const title = titleMatch ? titleMatch[1].trim() : undefined;
164
+ const text = raw
165
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
166
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
167
+ .replace(/<[^>]+>/g, ' ')
168
+ .replace(/&nbsp;/g, ' ')
169
+ .replace(/&amp;/g, '&')
170
+ .replace(/&lt;/g, '<')
171
+ .replace(/&gt;/g, '>')
172
+ .replace(/&quot;/g, '"')
173
+ .replace(/&#39;/g, "'")
174
+ .replace(/\s+/g, ' ')
175
+ .trim();
176
+ return { text, title, contentType };
177
+ }
178
+ // Plaintext, markdown, etc.
179
+ return { text: raw, contentType };
180
+ }
181
+ finally {
182
+ clearTimeout(timer);
183
+ }
184
+ }
185
+ export async function brainSave(input) {
186
+ const slug = sanitizeSlug(input.slug || 'chat-saves');
187
+ const isUrl = looksLikeUrl(input.content);
188
+ let text = input.content;
189
+ let title = input.title;
190
+ let urlContentType;
191
+ if (isUrl) {
192
+ const fetched = await fetchUrlText(input.content);
193
+ text = fetched.text;
194
+ title = title || fetched.title || input.content;
195
+ urlContentType = fetched.contentType;
196
+ }
197
+ if (!text || !text.trim()) {
198
+ throw new Error('brain_save: content is empty after fetch/extract');
199
+ }
200
+ // Stable id: caller-provided OR URL-as-id OR sha-style hash of text
201
+ const externalId = input.externalId
202
+ || (isUrl ? input.content : fallbackExternalId(slug, 0, text));
203
+ const metadata = {
204
+ savedFromChat: true,
205
+ sourceType: isUrl ? 'url' : 'text',
206
+ };
207
+ if (isUrl)
208
+ metadata.url = input.content;
209
+ if (urlContentType)
210
+ metadata.contentType = urlContentType;
211
+ if (input.tags && input.tags.length)
212
+ metadata.tags = input.tags;
213
+ const result = await ingestBrainRecords(slug, [{
214
+ title: title || 'Untitled',
215
+ externalId,
216
+ content: text,
217
+ metadata,
218
+ }]);
219
+ return { ...result, sourceType: isUrl ? 'url' : 'text' };
220
+ }
128
221
  export function registerBrainTools(server) {
222
+ server.tool('brain_save', 'Save a single piece of content (text or URL) to the brain right now. Use when the user says things like "remember this", "save this article", "ingest this URL", "add to memory". Routes through the same distillation pipeline the dashboard\'s Seed tab uses (chunking + LLM summary + vault note + memory index + knowledge graph). For batch ingestion or recurring feeds, see brain_ingest_folder + schedule_skill.', {
223
+ content: z.string().describe('Either a URL (fetched + text-extracted) or raw text content.'),
224
+ title: z.string().optional().describe('Optional human-readable title. Inferred from <title> tag for HTML URLs.'),
225
+ slug: z.string().optional().describe('Logical bucket (folder) the record lands in under 04-Ingest/<slug>/. Default: "chat-saves".'),
226
+ externalId: z.string().optional().describe('Stable id so repeat saves dedupe (e.g. URL, message id). Default: hash of content.'),
227
+ tags: z.array(z.string()).optional().describe('Free-form tags persisted in frontmatter for later filtering/recall.'),
228
+ }, async (input) => {
229
+ try {
230
+ const result = await brainSave(input);
231
+ const where = `04-Ingest/${result.slug}/`;
232
+ return textResult(`Saved to brain (${result.sourceType}): "${(input.title || input.content).slice(0, 80)}"\n` +
233
+ `Folder: ${where} · Pipeline: ${result.pipeline.recordsIn} in · ${result.pipeline.recordsWritten} written · ${result.pipeline.recordsSkipped} skipped` +
234
+ (result.pipeline.recordsFailed > 0 ? ` · ${result.pipeline.recordsFailed} failed` : ''));
235
+ }
236
+ catch (err) {
237
+ const msg = err instanceof Error ? err.message : String(err);
238
+ logger.error({ err }, 'brain_save: failed');
239
+ return textResult(`brain_save failed: ${msg}`);
240
+ }
241
+ });
129
242
  server.tool('brain_ingest_folder', 'Ingest a batch of records into the brain under a named slug. Sends records directly into the distillation pipeline (chunking, LLM summarization, vault note write, memory indexing, knowledge graph write). Use at the end of Connector Feed cron jobs. Safe to re-run — records with the same externalId update the same distilled note.', {
130
243
  slug: z.string().describe('Feed slug (matches 04-Ingest/<slug> folder). Lowercase, hyphen-separated.'),
131
244
  records: z.array(z.object({
@@ -31,6 +31,7 @@ import { registerBackgroundTaskTools } from './background-task-tools.js';
31
31
  import { registerDecisionReflectionTools } from './decision-reflection-tools.js';
32
32
  import { registerBuilderTools } from './builder-tools.js';
33
33
  import { registerSkillTools } from './skill-tools.js';
34
+ import { registerScheduleTools } from './schedule-tools.js';
34
35
  // ── Server ──────────────────────────────────────────────────────────────
35
36
  const serverName = (env['ASSISTANT_NAME'] ?? 'Clementine').toLowerCase() + '-tools';
36
37
  const server = new McpServer({ name: serverName, version: '1.0.0' });
@@ -75,6 +76,7 @@ registerBackgroundTaskTools(scopedServer);
75
76
  registerDecisionReflectionTools(scopedServer);
76
77
  registerBuilderTools(scopedServer);
77
78
  registerSkillTools(scopedServer);
79
+ registerScheduleTools(scopedServer);
78
80
  // ── Main ────────────────────────────────────────────────────────────────
79
81
  async function main() {
80
82
  // Initialize memory store and run full sync on startup
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Clementine TypeScript — Schedule MCP tools.
3
+ *
4
+ * Thin chat-facing wrappers around src/agent/schedule-registry.ts so
5
+ * the agent can compose the full automation flow from natural language:
6
+ *
7
+ * user: "scrape Drive every 2 days for AI articles, save to memory"
8
+ * agent: 1. create_skill('drive-ai-scraper', { body: '...' })
9
+ * 2. schedule_skill('drive-ai-scraper', '0 9 *_/2 * *') (comment elides slash)
10
+ *
11
+ * 1.18.145 — closes the chat-parity gap on the automation side. Before
12
+ * this, the agent could create skills but had no way to schedule them
13
+ * recurringly without dropping to the dashboard.
14
+ */
15
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
16
+ export declare function registerScheduleTools(server: McpServer): void;
17
+ //# sourceMappingURL=schedule-tools.d.ts.map
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Clementine TypeScript — Schedule MCP tools.
3
+ *
4
+ * Thin chat-facing wrappers around src/agent/schedule-registry.ts so
5
+ * the agent can compose the full automation flow from natural language:
6
+ *
7
+ * user: "scrape Drive every 2 days for AI articles, save to memory"
8
+ * agent: 1. create_skill('drive-ai-scraper', { body: '...' })
9
+ * 2. schedule_skill('drive-ai-scraper', '0 9 *_/2 * *') (comment elides slash)
10
+ *
11
+ * 1.18.145 — closes the chat-parity gap on the automation side. Before
12
+ * this, the agent could create skills but had no way to schedule them
13
+ * recurringly without dropping to the dashboard.
14
+ */
15
+ import { z } from 'zod';
16
+ import cron from 'node-cron';
17
+ import { listSchedules, getSchedule, setSchedule, removeSchedule, enableSchedule, } from '../agent/schedule-registry.js';
18
+ import { loadSkillByName } from '../agent/skill-extractor.js';
19
+ import { textResult, logger } from './shared.js';
20
+ export function registerScheduleTools(server) {
21
+ server.tool('schedule_skill', 'Schedule a skill to run automatically on a cron expression. Pair with create_skill to build "recurring brain feeds" or any other automation from chat. The skill must already exist in the catalog. Idempotent — calling twice for the same skill updates the existing schedule. Use enabled=false to pause without losing the schedule.', {
22
+ skillName: z.string().describe('The skill slug (must already exist in the catalog — call create_skill first if needed).'),
23
+ schedule: z.string().describe('Cron expression. Examples: "0 9 * * *" = daily 9am, "0 9 */2 * *" = every 2 days at 9am, "0 */4 * * *" = every 4 hours, "0 7 * * 1-5" = weekdays at 7am.'),
24
+ enabled: z.boolean().optional().describe('When false, schedule is saved but the runner skips it. Default: true.'),
25
+ agentSlug: z.string().nullable().optional().describe('Optional: run as a hired agent (e.g. "ross-the-sdr"). Default: null = Clementine.'),
26
+ }, async ({ skillName, schedule, enabled, agentSlug }) => {
27
+ // Validate cron expression up-front so the user gets a clear
28
+ // error before we touch the registry.
29
+ if (!cron.validate(schedule)) {
30
+ return textResult(`schedule_skill: "${schedule}" is not a valid cron expression. Try something like "0 9 * * *" (daily 9am).`);
31
+ }
32
+ // Validate the skill exists. Without this, the schedule would
33
+ // silently sit in the registry and the cron scheduler would skip
34
+ // it on every fire — confusing failure mode.
35
+ const skill = loadSkillByName(skillName, agentSlug ?? undefined);
36
+ if (!skill) {
37
+ return textResult(`schedule_skill: skill "${skillName}" not found${agentSlug ? ` (in agent "${agentSlug}" scope)` : ''}. ` +
38
+ `Create it first with create_skill, then schedule it.`);
39
+ }
40
+ try {
41
+ const entry = setSchedule(skillName, {
42
+ schedule,
43
+ enabled: enabled ?? true,
44
+ agentSlug: agentSlug ?? null,
45
+ });
46
+ logger.info({ skillName, schedule, enabled: entry.enabled, agentSlug: entry.agentSlug }, 'schedule_skill: scheduled');
47
+ return textResult(`Scheduled "${skillName}" to run on "${schedule}"` +
48
+ (entry.enabled ? '' : ' (DISABLED — flip enabled:true to start firing)') +
49
+ `. View on the Tasks page or call list_schedules to confirm.`);
50
+ }
51
+ catch (err) {
52
+ const msg = err instanceof Error ? err.message : String(err);
53
+ logger.error({ err, skillName }, 'schedule_skill: failed');
54
+ return textResult(`schedule_skill failed: ${msg}`);
55
+ }
56
+ });
57
+ server.tool('list_schedules', 'List every scheduled skill and its cron expression. Returns the same data the dashboard\'s Tasks page shows for the SKILL-tagged rows.', {}, async () => {
58
+ try {
59
+ const entries = listSchedules();
60
+ if (entries.length === 0) {
61
+ return textResult('No scheduled skills yet. Use schedule_skill to create one.');
62
+ }
63
+ const lines = [`${entries.length} scheduled skill${entries.length === 1 ? '' : 's'}:`];
64
+ for (const e of entries) {
65
+ const status = e.enabled ? '✓' : '⏸';
66
+ const owner = e.agentSlug ? ` (as ${e.agentSlug})` : '';
67
+ lines.push(`${status} ${e.skillName} — ${e.schedule}${owner}`);
68
+ }
69
+ return textResult(lines.join('\n'));
70
+ }
71
+ catch (err) {
72
+ const msg = err instanceof Error ? err.message : String(err);
73
+ return textResult(`list_schedules failed: ${msg}`);
74
+ }
75
+ });
76
+ server.tool('unschedule_skill', 'Remove a skill\'s schedule entirely. The skill stays in the catalog and can still be run manually or rescheduled later. Use pause_schedule (enabled:false via schedule_skill) for a temporary pause instead.', {
77
+ skillName: z.string().describe('The skill slug to unschedule.'),
78
+ }, async ({ skillName }) => {
79
+ const existing = getSchedule(skillName);
80
+ if (!existing) {
81
+ return textResult(`unschedule_skill: "${skillName}" wasn't scheduled — nothing to do.`);
82
+ }
83
+ try {
84
+ removeSchedule(skillName);
85
+ logger.info({ skillName }, 'unschedule_skill: removed');
86
+ return textResult(`Unscheduled "${skillName}". The skill stays in the catalog and can be run manually.`);
87
+ }
88
+ catch (err) {
89
+ const msg = err instanceof Error ? err.message : String(err);
90
+ return textResult(`unschedule_skill failed: ${msg}`);
91
+ }
92
+ });
93
+ server.tool('pause_schedule', 'Pause or resume an existing scheduled skill without losing its cron expression. Equivalent to schedule_skill with enabled:false but doesn\'t require re-passing the schedule string.', {
94
+ skillName: z.string().describe('The skill slug.'),
95
+ enabled: z.boolean().describe('true = resume firing, false = pause.'),
96
+ }, async ({ skillName, enabled }) => {
97
+ const updated = enableSchedule(skillName, enabled);
98
+ if (!updated) {
99
+ return textResult(`pause_schedule: "${skillName}" isn't scheduled. Use schedule_skill to create the schedule first.`);
100
+ }
101
+ logger.info({ skillName, enabled }, 'pause_schedule: updated');
102
+ return textResult(`${enabled ? 'Resumed' : 'Paused'} "${skillName}" (${updated.schedule}).`);
103
+ });
104
+ }
105
+ //# sourceMappingURL=schedule-tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.144",
3
+ "version": "1.18.146",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",