clementine-agent 1.18.126 → 1.18.128

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.
@@ -2913,6 +2913,22 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2913
2913
  catch {
2914
2914
  // Non-fatal — extraction logging should never block memory writes
2915
2915
  }
2916
+ // 1.18.127 — surface a "📝 Noted: <fact>" toast in the
2917
+ // dashboard chat. Fire-and-forget through the memory-events
2918
+ // bus; if no listener (no dashboard active), nothing happens.
2919
+ try {
2920
+ const { emitMemoryExtraction, summarizeExtractionInput } = await import('./memory-events.js');
2921
+ emitMemoryExtraction({
2922
+ sessionKey: sessionKey ?? 'unknown',
2923
+ toolName: toolBaseName,
2924
+ summary: summarizeExtractionInput((block.input ?? {})),
2925
+ agentSlug: profile?.slug ?? null,
2926
+ at: new Date().toISOString(),
2927
+ });
2928
+ }
2929
+ catch {
2930
+ // Non-fatal — visibility never blocks the write
2931
+ }
2916
2932
  }
2917
2933
  }
2918
2934
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Memory extraction event bus — 1.18.127.
3
+ *
4
+ * Lets the dashboard surface "📝 Noted: <fact>" toasts when the
5
+ * background auto-extraction Haiku writes to MEMORY.md / user_model
6
+ * after a chat exchange. Today extraction is fully silent: the user
7
+ * sees nothing happen, even though the agent may have just learned
8
+ * something important.
9
+ *
10
+ * Pattern: a single module-level listener slot. Dashboard registers
11
+ * one callback at startup; assistant.ts emits when an extraction tool
12
+ * call lands. Zero ordering guarantees, zero retention — fire-and-
13
+ * forget, just like the extraction itself.
14
+ *
15
+ * Why not EventEmitter: a single listener is enough today and a class
16
+ * import would be dead weight. Trivially upgrade-able if we ever need
17
+ * a fan-out.
18
+ */
19
+ export interface MemoryExtractionEvent {
20
+ /** Source session that produced the extraction (e.g. "discord:dm:owner",
21
+ * "dashboard:web", "cron:morning-briefing"). */
22
+ sessionKey: string;
23
+ /** The MCP tool the extractor called — memory_write, note_create,
24
+ * task_add, note_take, user_model. */
25
+ toolName: string;
26
+ /** A short human-readable summary of what was learned. Built from the
27
+ * tool input by the emitter — typically the `content` or `text` field
28
+ * truncated to ~120 chars. Empty string when the input shape is unknown. */
29
+ summary: string;
30
+ /** Active hired-agent slug, when applicable. null = Clementine herself. */
31
+ agentSlug: string | null;
32
+ /** ISO timestamp of when the extraction landed. */
33
+ at: string;
34
+ }
35
+ type Listener = (event: MemoryExtractionEvent) => void;
36
+ /** Register the dashboard's SSE broadcaster. Calling again replaces the
37
+ * previous listener (one-shot slot). Pass `null` to clear. */
38
+ export declare function setMemoryExtractionListener(fn: Listener | null): void;
39
+ /** Emit an extraction event. Errors thrown by the listener are
40
+ * swallowed — visibility must never block the actual write. */
41
+ export declare function emitMemoryExtraction(event: MemoryExtractionEvent): void;
42
+ /**
43
+ * Pull a short, user-facing summary out of an MCP tool input payload.
44
+ * Each tool stores its content in a different key, so we look at the
45
+ * usual suspects in priority order and truncate.
46
+ */
47
+ export declare function summarizeExtractionInput(toolInput: Record<string, unknown>): string;
48
+ export {};
49
+ //# sourceMappingURL=memory-events.d.ts.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Memory extraction event bus — 1.18.127.
3
+ *
4
+ * Lets the dashboard surface "📝 Noted: <fact>" toasts when the
5
+ * background auto-extraction Haiku writes to MEMORY.md / user_model
6
+ * after a chat exchange. Today extraction is fully silent: the user
7
+ * sees nothing happen, even though the agent may have just learned
8
+ * something important.
9
+ *
10
+ * Pattern: a single module-level listener slot. Dashboard registers
11
+ * one callback at startup; assistant.ts emits when an extraction tool
12
+ * call lands. Zero ordering guarantees, zero retention — fire-and-
13
+ * forget, just like the extraction itself.
14
+ *
15
+ * Why not EventEmitter: a single listener is enough today and a class
16
+ * import would be dead weight. Trivially upgrade-able if we ever need
17
+ * a fan-out.
18
+ */
19
+ let listener = null;
20
+ /** Register the dashboard's SSE broadcaster. Calling again replaces the
21
+ * previous listener (one-shot slot). Pass `null` to clear. */
22
+ export function setMemoryExtractionListener(fn) {
23
+ listener = fn;
24
+ }
25
+ /** Emit an extraction event. Errors thrown by the listener are
26
+ * swallowed — visibility must never block the actual write. */
27
+ export function emitMemoryExtraction(event) {
28
+ if (!listener)
29
+ return;
30
+ try {
31
+ listener(event);
32
+ }
33
+ catch {
34
+ /* never throw out of the extraction path */
35
+ }
36
+ }
37
+ /**
38
+ * Pull a short, user-facing summary out of an MCP tool input payload.
39
+ * Each tool stores its content in a different key, so we look at the
40
+ * usual suspects in priority order and truncate.
41
+ */
42
+ export function summarizeExtractionInput(toolInput) {
43
+ const candidates = ['content', 'text', 'fact', 'value', 'note', 'message', 'task'];
44
+ for (const key of candidates) {
45
+ const v = toolInput[key];
46
+ if (typeof v === 'string' && v.trim()) {
47
+ return v.trim().length > 120 ? v.trim().slice(0, 120) + '…' : v.trim();
48
+ }
49
+ }
50
+ return '';
51
+ }
52
+ //# sourceMappingURL=memory-events.js.map
@@ -306,9 +306,25 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
306
306
  const skillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
307
307
  const suppressedNamesRaw = memoryStore
308
308
  ?.getSkillsToSuppress?.(agentSlug);
309
- const suppressedNames = Array.isArray(suppressedNamesRaw)
309
+ const autoSuppressed = Array.isArray(suppressedNamesRaw)
310
310
  ? new Set(suppressedNamesRaw)
311
311
  : (suppressedNamesRaw ?? undefined);
312
+ // 1.18.127 — merge automatic feedback-driven suppressions (from the
313
+ // memory store) with manual user toggles (from skill-suppressions.json).
314
+ // Both sets pass through to loadSkillByName + searchSkills under the
315
+ // same `suppressedNames` parameter — the runtime doesn't care which
316
+ // source flagged a skill.
317
+ let suppressedNames = autoSuppressed;
318
+ try {
319
+ const { getManualSuppressions } = await import('./skill-suppressions.js');
320
+ const manual = getManualSuppressions(agentSlug);
321
+ if (manual.size > 0) {
322
+ suppressedNames = new Set([...(autoSuppressed ?? []), ...manual]);
323
+ }
324
+ }
325
+ catch (err) {
326
+ logger.debug({ err }, 'manual suppression read failed (non-fatal)');
327
+ }
312
328
  const prepared = [];
313
329
  const seen = new Set();
314
330
  // 1. Load pinned skills first via exact slug lookup. When the cron has
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Manual skill suppression list — 1.18.127.
3
+ *
4
+ * Complements the automatic feedback-driven suppression in
5
+ * `MemoryStore.getSkillsToSuppress` (which suppresses skills that
6
+ * accumulate ≥3 negative ratings with >50% negative rate in the last 60
7
+ * days). This file owns the *manual* list — what the user explicitly
8
+ * toggles in the dashboard ("don't ever auto-match this skill again").
9
+ *
10
+ * Storage: a single JSON file at `~/.clementine/skill-suppressions.json`
11
+ * with the shape:
12
+ *
13
+ * {
14
+ * "global": ["my-buggy-skill", "stale-procedure"],
15
+ * "ross-the-sdr": ["sasha-only-skill"]
16
+ * }
17
+ *
18
+ * Merged with the auto-suppression set inside `buildSkillContext` so
19
+ * the runtime sees one combined Set<string>. No schema migration; missing
20
+ * file = empty list.
21
+ */
22
+ export interface SuppressionFile {
23
+ /** Global suppressions apply to every agent (Clementine + every hired agent). */
24
+ global: string[];
25
+ /** Per-agent suppressions apply only when the named agent is running. */
26
+ [agentSlug: string]: string[];
27
+ }
28
+ /**
29
+ * Read the merged set of manually suppressed skill names for a given
30
+ * agent context. Includes the global list always; adds the agent-specific
31
+ * list when `agentSlug` is provided.
32
+ */
33
+ export declare function getManualSuppressions(agentSlug?: string | null): Set<string>;
34
+ /** List the full suppression file as the dashboard sees it. */
35
+ export declare function listAllSuppressions(): SuppressionFile;
36
+ /**
37
+ * Toggle a skill's suppression state for a given scope. Returns the
38
+ * resulting per-scope list so the UI can re-render without a refetch.
39
+ *
40
+ * - `scope === 'global'` writes to `data.global`
41
+ * - any other scope value treats it as an agent slug
42
+ */
43
+ export declare function setSuppression(skillName: string, scope: string, suppressed: boolean): {
44
+ scope: string;
45
+ list: string[];
46
+ };
47
+ //# sourceMappingURL=skill-suppressions.d.ts.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Manual skill suppression list — 1.18.127.
3
+ *
4
+ * Complements the automatic feedback-driven suppression in
5
+ * `MemoryStore.getSkillsToSuppress` (which suppresses skills that
6
+ * accumulate ≥3 negative ratings with >50% negative rate in the last 60
7
+ * days). This file owns the *manual* list — what the user explicitly
8
+ * toggles in the dashboard ("don't ever auto-match this skill again").
9
+ *
10
+ * Storage: a single JSON file at `~/.clementine/skill-suppressions.json`
11
+ * with the shape:
12
+ *
13
+ * {
14
+ * "global": ["my-buggy-skill", "stale-procedure"],
15
+ * "ross-the-sdr": ["sasha-only-skill"]
16
+ * }
17
+ *
18
+ * Merged with the auto-suppression set inside `buildSkillContext` so
19
+ * the runtime sees one combined Set<string>. No schema migration; missing
20
+ * file = empty list.
21
+ */
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ // Resolve lazily on each call so test environments (which override
26
+ // CLEMENTINE_HOME inside beforeEach) see the fresh value rather than
27
+ // the value snapshot at module-load time.
28
+ function baseDir() {
29
+ return process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
30
+ }
31
+ function suppressionsPath() {
32
+ return path.join(baseDir(), 'skill-suppressions.json');
33
+ }
34
+ function readFile() {
35
+ const filePath = suppressionsPath();
36
+ if (!existsSync(filePath))
37
+ return { global: [] };
38
+ try {
39
+ const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
40
+ if (!raw || typeof raw !== 'object')
41
+ return { global: [] };
42
+ const out = { global: Array.isArray(raw.global) ? raw.global.map(String) : [] };
43
+ for (const key of Object.keys(raw)) {
44
+ if (key === 'global')
45
+ continue;
46
+ if (Array.isArray(raw[key]))
47
+ out[key] = raw[key].map(String);
48
+ }
49
+ return out;
50
+ }
51
+ catch {
52
+ return { global: [] };
53
+ }
54
+ }
55
+ function writeFile(data) {
56
+ const dir = baseDir();
57
+ if (!existsSync(dir))
58
+ mkdirSync(dir, { recursive: true });
59
+ writeFileSync(suppressionsPath(), JSON.stringify(data, null, 2));
60
+ }
61
+ /**
62
+ * Read the merged set of manually suppressed skill names for a given
63
+ * agent context. Includes the global list always; adds the agent-specific
64
+ * list when `agentSlug` is provided.
65
+ */
66
+ export function getManualSuppressions(agentSlug) {
67
+ const data = readFile();
68
+ const merged = new Set(data.global ?? []);
69
+ if (agentSlug && Array.isArray(data[agentSlug])) {
70
+ for (const name of data[agentSlug])
71
+ merged.add(name);
72
+ }
73
+ return merged;
74
+ }
75
+ /** List the full suppression file as the dashboard sees it. */
76
+ export function listAllSuppressions() {
77
+ return readFile();
78
+ }
79
+ /**
80
+ * Toggle a skill's suppression state for a given scope. Returns the
81
+ * resulting per-scope list so the UI can re-render without a refetch.
82
+ *
83
+ * - `scope === 'global'` writes to `data.global`
84
+ * - any other scope value treats it as an agent slug
85
+ */
86
+ export function setSuppression(skillName, scope, suppressed) {
87
+ const data = readFile();
88
+ const key = scope === 'global' ? 'global' : scope;
89
+ const list = new Set(Array.isArray(data[key]) ? data[key] : []);
90
+ if (suppressed)
91
+ list.add(skillName);
92
+ else
93
+ list.delete(skillName);
94
+ data[key] = [...list].sort();
95
+ writeFile(data);
96
+ return { scope: key, list: data[key] };
97
+ }
98
+ //# sourceMappingURL=skill-suppressions.js.map
@@ -19,7 +19,7 @@ import { TunnelManager } from './tunnel.js';
19
19
  import { AgentManager } from '../agent/agent-manager.js';
20
20
  import { discoverMcpServers, getClaudeIntegrations } from '../agent/mcp-bridge.js';
21
21
  import { buildBuilderEnrichedMessage, builderSessionKey } from '../dashboard/builder/prompt.js';
22
- import { AGENTS_DIR, SESSIONS_FILE, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
22
+ import { AGENTS_DIR, MEMORY_FILE, SESSIONS_FILE, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
23
23
  import { parseTasks } from '../tools/shared.js';
24
24
  import { todayISO, CronRunLog } from '../gateway/cron-scheduler.js';
25
25
  import { goalsRouter } from './routes/goals.js';
@@ -3529,6 +3529,70 @@ export async function cmdDashboard(opts) {
3529
3529
  app.get('/api/memory', async (_req, res) => {
3530
3530
  res.json(await getMemory());
3531
3531
  });
3532
+ // ── MEMORY.md inline editor (1.18.127) ────────────────────────────
3533
+ // GET / PUT for the long-term memory file. Per-agent variant via
3534
+ // ?agent=<slug>. mtimeMs round-tripped so the dashboard can detect
3535
+ // an out-of-band edit (Obsidian, agent extraction) and refuse to
3536
+ // clobber it on save.
3537
+ function resolveMemoryPath(agentParam) {
3538
+ if (!agentParam || agentParam === 'global')
3539
+ return MEMORY_FILE;
3540
+ // Agent slug — must match an existing agent directory. Refuse traversal.
3541
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(agentParam)) {
3542
+ throw new Error('invalid agent slug');
3543
+ }
3544
+ return path.join(AGENTS_DIR, agentParam, 'MEMORY.md');
3545
+ }
3546
+ app.get('/api/memory/md', (req, res) => {
3547
+ try {
3548
+ const agentParam = typeof req.query.agent === 'string' ? req.query.agent : undefined;
3549
+ const filePath = resolveMemoryPath(agentParam);
3550
+ if (!existsSync(filePath)) {
3551
+ return res.json({ content: '', mtimeMs: 0, agentSlug: agentParam ?? 'global', exists: false, filePath });
3552
+ }
3553
+ const content = readFileSync(filePath, 'utf-8');
3554
+ const mtimeMs = statSync(filePath).mtimeMs;
3555
+ res.json({ content, mtimeMs, agentSlug: agentParam ?? 'global', exists: true, filePath });
3556
+ }
3557
+ catch (err) {
3558
+ res.status(400).json({ error: String(err instanceof Error ? err.message : err) });
3559
+ }
3560
+ });
3561
+ app.put('/api/memory/md', (req, res) => {
3562
+ try {
3563
+ const agentParam = typeof req.query.agent === 'string' ? req.query.agent : undefined;
3564
+ const filePath = resolveMemoryPath(agentParam);
3565
+ const body = (req.body ?? {});
3566
+ if (typeof body.content !== 'string') {
3567
+ return res.status(400).json({ error: 'content (string) required' });
3568
+ }
3569
+ // Conflict detection: if the file already exists and its mtime has
3570
+ // moved past what the client last saw, refuse the write so we don't
3571
+ // clobber an Obsidian save or an agent extraction. Client surfaces a
3572
+ // "reload?" toast on 409.
3573
+ if (existsSync(filePath) && typeof body.expectedMtimeMs === 'number' && body.expectedMtimeMs > 0) {
3574
+ const currentMtime = statSync(filePath).mtimeMs;
3575
+ // 2-second tolerance covers same-second saves under filesystem mtime resolution.
3576
+ if (currentMtime - body.expectedMtimeMs > 2000) {
3577
+ return res.status(409).json({
3578
+ error: 'File changed on disk since you loaded it. Reload to see the latest version.',
3579
+ currentMtimeMs: currentMtime,
3580
+ });
3581
+ }
3582
+ }
3583
+ // Ensure parent dir exists (per-agent MEMORY.md is created lazily).
3584
+ const parentDir = path.dirname(filePath);
3585
+ if (!existsSync(parentDir)) {
3586
+ mkdirSync(parentDir, { recursive: true });
3587
+ }
3588
+ writeFileSync(filePath, body.content);
3589
+ const mtimeMs = statSync(filePath).mtimeMs;
3590
+ res.json({ ok: true, mtimeMs, bytes: Buffer.byteLength(body.content, 'utf-8') });
3591
+ }
3592
+ catch (err) {
3593
+ res.status(500).json({ error: String(err instanceof Error ? err.message : err) });
3594
+ }
3595
+ });
3532
3596
  app.get('/api/logs', (req, res) => {
3533
3597
  const lines = parseInt(String(req.query.lines ?? '200'), 10);
3534
3598
  res.json({ content: getLogs(lines) });
@@ -3696,6 +3760,21 @@ export async function cmdDashboard(opts) {
3696
3760
  }
3697
3761
  // Let the lazy-gateway dispatcher publish deep_result events through SSE.
3698
3762
  dashboardSseBroadcast = broadcastEvent;
3763
+ // 1.18.127 — bridge memory-extraction events from assistant.ts → SSE.
3764
+ // The dashboard chat panel listens on the same SSE stream and renders
3765
+ // a "📝 Noted: <fact>" toast whenever the background extractor writes
3766
+ // something. Silent until then — no traffic when no extraction fires.
3767
+ (async () => {
3768
+ try {
3769
+ const { setMemoryExtractionListener } = await import('../agent/memory-events.js');
3770
+ setMemoryExtractionListener((event) => {
3771
+ broadcastEvent({ type: 'memory_extracted', data: event });
3772
+ });
3773
+ }
3774
+ catch (err) {
3775
+ console.warn('Failed to wire memory-extraction SSE bridge:', err);
3776
+ }
3777
+ })();
3699
3778
  // ── Builder event bridge ──────────────────────────────────────
3700
3779
  // Forward events from src/dashboard/builder/events.ts through SSE so the
3701
3780
  // Builder page can update its canvas live as the agent edits via MCP tools.
@@ -4504,6 +4583,41 @@ export async function cmdDashboard(opts) {
4504
4583
  res.status(500).json({ ok: false, error: String(err) });
4505
4584
  }
4506
4585
  });
4586
+ // ── Skill suppressions (1.18.127) ──────────────────────────────────
4587
+ // Lets the user manually suppress skills from auto-match retrieval —
4588
+ // a complement to the memory store's automatic feedback-driven
4589
+ // suppression. Storage is a single JSON file under ~/.clementine/.
4590
+ app.get('/api/skills/suppressions', async (_req, res) => {
4591
+ try {
4592
+ const { listAllSuppressions } = await import('../agent/skill-suppressions.js');
4593
+ res.json({ ok: true, suppressions: listAllSuppressions() });
4594
+ }
4595
+ catch (err) {
4596
+ res.status(500).json({ ok: false, error: String(err) });
4597
+ }
4598
+ });
4599
+ app.put('/api/skills/suppressions/:name', async (req, res) => {
4600
+ try {
4601
+ const name = req.params.name;
4602
+ if (!name) {
4603
+ res.status(400).json({ ok: false, error: 'name required' });
4604
+ return;
4605
+ }
4606
+ const body = (req.body ?? {});
4607
+ const suppressed = body.suppressed === true;
4608
+ const scope = typeof body.scope === 'string' && body.scope.length > 0 ? body.scope : 'global';
4609
+ // Slug-shape validation when scope is per-agent (anything other than "global").
4610
+ if (scope !== 'global' && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(scope)) {
4611
+ return res.status(400).json({ ok: false, error: 'invalid scope (must be "global" or a valid agent slug)' });
4612
+ }
4613
+ const { setSuppression } = await import('../agent/skill-suppressions.js');
4614
+ const result = setSuppression(name, scope, suppressed);
4615
+ res.json({ ok: true, ...result });
4616
+ }
4617
+ catch (err) {
4618
+ res.status(500).json({ ok: false, error: String(err) });
4619
+ }
4620
+ });
4507
4621
  // ── Skill migration (legacy .md → folder/SKILL.md) ─────────────────
4508
4622
  // Two endpoints: per-skill and bulk. Both wrap migrateLegacySkill /
4509
4623
  // migrateAllLegacySkills from skill-store.ts. The original .md is
@@ -7603,6 +7717,13 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7603
7717
  mcpServers: mcpInfo,
7604
7718
  composioConnected: plan.composioConnected,
7605
7719
  externalConnected: plan.externalConnected,
7720
+ // 1.18.127 — surface the scope-widening from 1.18.125 so the UI can
7721
+ // show "this skill brought in `Bash` + `gmail`" with attribution.
7722
+ // `widenedFromSkills.tools` and `.mcpServers` only contain entries
7723
+ // that were ADDED on top of the cron's own allowlists. Empty arrays
7724
+ // when the cron is unrestricted (skill scope was implicitly allowed)
7725
+ // or when no pinned skill widened anything.
7726
+ widenedFromSkills: plan.widenedFromSkills,
7606
7727
  tier: plan.tier,
7607
7728
  effort: plan.effort,
7608
7729
  maxBudgetUsd: plan.maxBudgetUsd ?? null,
@@ -18652,6 +18773,31 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
18652
18773
  </div>
18653
18774
  <div class="card-body" id="panel-memory"><div class="skel-block"><div class="skel-row med"></div><div class="skel-row"></div><div class="skel-row short"></div></div></div>
18654
18775
  </div>
18776
+ <!-- 1.18.127 — MEMORY.md inline editor. Lets the user seed
18777
+ long-term memory directly without leaving the dashboard.
18778
+ Per-agent toggle (global / Sasha / Ross / …) reads + writes
18779
+ the right MEMORY.md file. mtime conflict-detection blocks
18780
+ clobbering an Obsidian save mid-edit. -->
18781
+ <div class="card" style="margin-bottom:14px">
18782
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
18783
+ <span>Edit MEMORY.md</span>
18784
+ <div style="display:flex;align-items:center;gap:8px">
18785
+ <select id="memory-md-scope" onchange="loadMemoryMdEditor()" style="font-size:12px;padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary)">
18786
+ <option value="global">Global (Clementine)</option>
18787
+ </select>
18788
+ <button class="btn-sm" onclick="loadMemoryMdEditor()" title="Reload from disk" style="font-size:11px;padding:4px 10px">Reload</button>
18789
+ <button id="memory-md-save-btn" class="btn-sm btn-primary" onclick="saveMemoryMd()" style="font-size:11px;padding:4px 10px" disabled>Save</button>
18790
+ </div>
18791
+ </div>
18792
+ <div class="card-body" style="padding:14px">
18793
+ <div id="memory-md-status" style="font-size:11px;color:var(--text-muted);margin-bottom:8px">Loading…</div>
18794
+ <textarea id="memory-md-editor" placeholder="Loading…" style="width:100%;min-height:280px;padding:10px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.5;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);resize:vertical" oninput="onMemoryMdInput()"></textarea>
18795
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-top:8px;font-size:11px;color:var(--text-muted)">
18796
+ <span id="memory-md-counter">0 chars</span>
18797
+ <span id="memory-md-path" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace"></span>
18798
+ </div>
18799
+ </div>
18800
+ </div>
18655
18801
  <div class="card" style="margin-bottom:14px">
18656
18802
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
18657
18803
  <span>Recent writes</span>
@@ -20863,6 +21009,21 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
20863
21009
  <button class="btn-sm btn-primary" style="white-space:nowrap;padding:6px 12px;border-radius:6px;cursor:pointer" onclick="restartDaemonFromDashboard()">Restart Clementine</button>
20864
21010
  </div>
20865
21011
  <div id="budget-health-content" style="margin-bottom:16px"><div class="empty-state">Loading budget health...</div></div>
21012
+ <!-- 1.18.127 — Notification preferences. Single toggle for now;
21013
+ room to grow into a full notification settings card. -->
21014
+ <div class="card" style="margin-bottom:16px">
21015
+ <div class="card-header">Notifications</div>
21016
+ <div class="card-body" style="padding:14px 16px;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap">
21017
+ <div>
21018
+ <div style="font-size:13px;color:var(--text-primary);font-weight:500">Silent learning mode</div>
21019
+ <div style="font-size:11px;color:var(--text-muted);margin-top:4px">When off, I show a small toast every time I save a fact / note / task to memory after a chat.</div>
21020
+ </div>
21021
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
21022
+ <input type="checkbox" id="silent-learning-toggle" onchange="toggleSilentLearning(this.checked)">
21023
+ <span style="font-size:12px;color:var(--text-secondary)">Silence</span>
21024
+ </label>
21025
+ </div>
21026
+ </div>
20866
21027
  <div id="settings-content"><div class="empty-state">Loading settings...</div></div>
20867
21028
  </div>
20868
21029
  <div class="tab-pane" id="tab-settings-remote">
@@ -21093,6 +21254,21 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
21093
21254
  </div>
21094
21255
  </div>
21095
21256
 
21257
+ <!-- 1.18.128 — Project Context promoted to Basics. Was buried in
21258
+ the Scope tab where users were missing it. Selecting a project
21259
+ gives the task that project's CLAUDE.md, MCP config, and cwd —
21260
+ usually the single most impactful field after Prompt. -->
21261
+ <div class="cron-section-card" data-config-tab="basics">
21262
+ <h4>Project Context <span style="color:var(--text-muted);font-weight:normal;font-size:13px">(optional)</span></h4>
21263
+ <p class="cron-section-desc">Run this task inside a project directory. The agent picks up that project's <code>CLAUDE.md</code>, MCP config, and any context files alongside the cwd.</p>
21264
+ <div class="form-group">
21265
+ <select id="cron-workdir">
21266
+ <option value="">None — runs in default context</option>
21267
+ </select>
21268
+ <div class="form-hint">No projects yet? <a href="#" onclick="navigateTo(\\x27settings\\x27, { tab: \\x27projects\\x27 }); closeCronModal(); return false" style="color:var(--accent)">Add one →</a></div>
21269
+ </div>
21270
+ </div>
21271
+
21096
21272
  <!-- Schedule -->
21097
21273
  <div class="cron-section-card" data-config-tab="basics">
21098
21274
  <h4>Schedule</h4>
@@ -21288,25 +21464,18 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
21288
21464
  </div>
21289
21465
  </div>
21290
21466
 
21291
- <!-- ── Scope: where the task can read/write ── -->
21467
+ <!-- ── Scope: extra read directories beyond the project cwd ── -->
21468
+ <!-- 1.18.128 — Project Context picker moved up to Basics. This
21469
+ section now only owns Additional read directories, which is
21470
+ a power-user feature anyway. -->
21292
21471
  <div class="cron-section-card" data-config-tab="scope">
21293
21472
  <h4>Scope</h4>
21294
- <p class="cron-section-desc">Where the agent runs and what files it can read.</p>
21295
- <div class="form-row">
21296
- <div class="form-group">
21297
- <label class="form-label">Project Context <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
21298
- <select id="cron-workdir">
21299
- <option value="">None — runs in default context</option>
21300
- </select>
21301
- <div class="form-hint">Run inside a project directory. Agent gets that project's CLAUDE.md.</div>
21302
- </div>
21303
- </div>
21304
- <!-- PRD Phase 1: read scope beyond cwd. One absolute path per line. -->
21473
+ <p class="cron-section-desc">Extra directories the agent gets read access to beyond the project cwd. Most tasks won't need this.</p>
21305
21474
  <div class="form-row">
21306
21475
  <div class="form-group" style="flex:1">
21307
21476
  <label class="form-label">Additional read directories <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
21308
21477
  <textarea id="cron-add-dirs" rows="2" placeholder="/Users/me/notes&#10;/Users/me/clients/acme" style="font-family:'JetBrains Mono',monospace;font-size:11px"></textarea>
21309
- <div class="form-hint">One absolute path per line. The agent gets read access to these in addition to the Project Context cwd.</div>
21478
+ <div class="form-hint">One absolute path per line. The Project Context above already gives the agent its cwd; use this for extra read scope only.</div>
21310
21479
  </div>
21311
21480
  </div>
21312
21481
  </div>
@@ -22820,6 +22989,7 @@ function switchTab(group, tab) {
22820
22989
  if (tab === 'search') {
22821
22990
  // Consolidated Memory tab: search results + stats + MEMORY.md + recent writes + supersedes + coverage strip.
22822
22991
  refreshMemory();
22992
+ if (typeof loadMemoryMdEditor === 'function') loadMemoryMdEditor();
22823
22993
  if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
22824
22994
  if (typeof refreshRecentEpisodes === 'function') refreshRecentEpisodes();
22825
22995
  if (typeof refreshCommitments === 'function') refreshCommitments();
@@ -22848,6 +23018,7 @@ function switchTab(group, tab) {
22848
23018
  }
22849
23019
  if (group === 'settings') {
22850
23020
  if (tab === 'general' && typeof refreshSettings === 'function') refreshSettings();
23021
+ if (tab === 'general' && typeof initSilentLearningToggle === 'function') initSilentLearningToggle();
22851
23022
  if (tab === 'integrations') { refreshSalesforce(); refreshComposioConnections(); refreshToolPreferences(); refreshMcpServers(); refreshClaudeIntegrations(); }
22852
23023
  if (tab === 'remote') refreshRemoteAccess();
22853
23024
  if (tab === 'security') refreshAuthSessions();
@@ -23885,6 +24056,28 @@ function dismissRestartRequiredBanner() {
23885
24056
  if (existing) existing.remove();
23886
24057
  }
23887
24058
 
24059
+ // 1.18.127 — silent learning mode toggle. Persisted to localStorage so
24060
+ // the preference survives page reloads. The dashboard SSE handler at
24061
+ // line ~39660 reads this flag to decide whether to render extraction
24062
+ // toasts when the background memory extractor writes facts.
24063
+ function toggleSilentLearning(silent) {
24064
+ try {
24065
+ if (silent) localStorage.setItem('clem-silent-learning', '1');
24066
+ else localStorage.removeItem('clem-silent-learning');
24067
+ toast(silent ? 'Silent learning ON — extraction toasts hidden.' : 'Silent learning OFF — you\\'ll see a toast when I save facts.', 'info');
24068
+ } catch (_) { /* localStorage may be disabled */ }
24069
+ }
24070
+
24071
+ // Restore the toggle state on page load so the checkbox reflects the
24072
+ // user's last preference. Defaults to OFF (visible toasts) for new users.
24073
+ function initSilentLearningToggle() {
24074
+ try {
24075
+ var box = document.getElementById('silent-learning-toggle');
24076
+ if (!box) return;
24077
+ box.checked = localStorage.getItem('clem-silent-learning') === '1';
24078
+ } catch (_) { /* non-fatal */ }
24079
+ }
24080
+
23888
24081
  async function restartDaemonFromDashboard(skipConfirm) {
23889
24082
  if (!skipConfirm && !confirm('Restart Clementine now? Active work may pause briefly while the daemon reloads.')) return;
23890
24083
  toast('Restarting Clementine...', 'info');
@@ -26911,9 +27104,14 @@ async function refreshProjects(preloaded) {
26911
27104
  ? '<div style="color:var(--accent);margin-bottom:4px;font-size:12px">' + esc(p.userDescription) + '</div>'
26912
27105
  : '';
26913
27106
  const idx = projectsData.indexOf(p);
27107
+ // 1.18.128 — "+ New task in this project" CTA: opens the cron creation
27108
+ // modal with the project pre-selected as Project Context. Closes the
27109
+ // mental gap between "I have a project with built-up context" and
27110
+ // "I need to schedule a task that uses it."
27111
+ const newTaskBtn = '<button class="btn btn-sm" style="font-size:11px" onclick="openCronModalForProject(' + idx + ')" title="Create a scheduled task that runs inside this project">+ New task</button>';
26914
27112
  const linkBtn = p.linked
26915
- ? '<button class="btn btn-sm" style="font-size:11px" onclick="openProjectEditorByIdx(' + idx + ')">Edit</button> <button class="btn btn-sm btn-danger" style="font-size:11px" onclick="unlinkProjectByIdx(' + idx + ')">Unlink</button>'
26916
- : '<button class="btn btn-sm btn-primary" style="font-size:11px" onclick="openProjectEditorByIdx(' + idx + ')">Link</button>';
27113
+ ? newTaskBtn + ' <button class="btn btn-sm" style="font-size:11px" onclick="openProjectEditorByIdx(' + idx + ')">Edit</button> <button class="btn btn-sm btn-danger" style="font-size:11px" onclick="unlinkProjectByIdx(' + idx + ')">Unlink</button>'
27114
+ : newTaskBtn + ' <button class="btn btn-sm btn-primary" style="font-size:11px" onclick="openProjectEditorByIdx(' + idx + ')">Link</button>';
26917
27115
  html += '<div class="card" style="cursor:default">'
26918
27116
  + '<div class="card-header" style="display:flex;align-items:center;justify-content:space-between">'
26919
27117
  + '<strong>' + esc(p.name) + '</strong>'
@@ -27275,12 +27473,18 @@ async function _openSkillModal(opts) {
27275
27473
  + '<input id="skill-modal-name" type="text" placeholder="e.g. morning-briefing" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px;font-family:\\x27JetBrains Mono\\x27,monospace">'
27276
27474
  + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Display title <span style="color:var(--text-muted)">(optional, friendlier name)</span></label>'
27277
27475
  + '<input id="skill-modal-title" type="text" placeholder="e.g. Morning Briefing" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px">'
27278
- + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Description <span style="color:var(--text-muted)">(what this skill does — used by Claude to know when to apply it)</span></label>'
27279
- + '<textarea id="skill-modal-desc" rows="2" placeholder="One paragraph: what does this skill do, when should Claude run it?" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px;font-family:inherit;resize:vertical"></textarea>'
27476
+ + '<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:4px">'
27477
+ + '<label style="font-size:12px;color:var(--text-secondary);font-weight:500">Description <span style="color:var(--text-muted)">(what this skill does used by Claude to know when to apply it)</span></label>'
27478
+ + '<span id="skill-modal-desc-counter" style="font-size:10px;color:var(--text-muted);font-variant-numeric:tabular-nums">0 / 1024 chars</span>'
27479
+ + '</div>'
27480
+ + '<textarea id="skill-modal-desc" rows="2" oninput="updateSkillModalCounters()" placeholder="One paragraph: what does this skill do, when should Claude run it?" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px;font-family:inherit;resize:vertical"></textarea>'
27280
27481
  + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Allowed tools <span style="color:var(--text-muted)">(comma-separated, leave blank for default)</span></label>'
27281
27482
  + '<input id="skill-modal-tools" type="text" placeholder="e.g. Read, Bash, mcp__supabase__list_tables" style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px">'
27282
- + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Procedure <span style="color:var(--text-muted)">(Markdown — the actual steps Claude follows)</span></label>'
27283
- + '<textarea id="skill-modal-body" rows="14" placeholder="# Morning Briefing\\n\\nSteps Claude follows when this skill is invoked.\\n\\n1. Check the inbox.\\n2. Summarize.\\n3. Send to Discord." style="width:100%;padding:10px 12px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-family:\\x27JetBrains Mono\\x27,monospace;line-height:1.55;resize:vertical"></textarea>'
27483
+ + '<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:4px">'
27484
+ + '<label style="font-size:12px;color:var(--text-secondary);font-weight:500">Procedure <span style="color:var(--text-muted)">(Markdown — the actual steps Claude follows)</span></label>'
27485
+ + '<span id="skill-modal-body-counter" style="font-size:10px;color:var(--text-muted);font-variant-numeric:tabular-nums">0 lines</span>'
27486
+ + '</div>'
27487
+ + '<textarea id="skill-modal-body" rows="14" oninput="updateSkillModalCounters()" placeholder="# Morning Briefing\\n\\nSteps Claude follows when this skill is invoked.\\n\\n1. Check the inbox.\\n2. Summarize.\\n3. Send to Discord." style="width:100%;padding:10px 12px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-family:\\x27JetBrains Mono\\x27,monospace;line-height:1.55;resize:vertical"></textarea>'
27284
27488
  + '<div id="skill-modal-error" style="display:none;color:var(--red);font-size:12px;margin-top:10px;padding:8px 10px;background:rgba(239,68,68,0.08);border:1px solid var(--red);border-radius:6px"></div>'
27285
27489
  + '</div>'
27286
27490
  + '<div style="display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border);background:var(--bg-secondary)">'
@@ -27303,6 +27507,31 @@ async function _openSkillModal(opts) {
27303
27507
  if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
27304
27508
  modal.style.display = 'flex';
27305
27509
  document.getElementById('skill-modal-name').focus();
27510
+ if (typeof updateSkillModalCounters === 'function') updateSkillModalCounters();
27511
+ }
27512
+
27513
+ // 1.18.127 — live char/line counters under description + body. Color
27514
+ // shifts at 80% (amber) and 100% (red) of the Anthropic-spec ceilings
27515
+ // so the user knows before submission rather than after.
27516
+ function updateSkillModalCounters() {
27517
+ var descEl = document.getElementById('skill-modal-desc');
27518
+ var descCounter = document.getElementById('skill-modal-desc-counter');
27519
+ var bodyEl = document.getElementById('skill-modal-body');
27520
+ var bodyCounter = document.getElementById('skill-modal-body-counter');
27521
+ if (descEl && descCounter) {
27522
+ var n = (descEl.value || '').length;
27523
+ var pct = n / 1024;
27524
+ var color = pct >= 1 ? 'var(--red)' : pct >= 0.8 ? 'var(--yellow)' : 'var(--text-muted)';
27525
+ descCounter.textContent = n + ' / 1024 chars';
27526
+ descCounter.style.color = color;
27527
+ }
27528
+ if (bodyEl && bodyCounter) {
27529
+ var lines = (bodyEl.value || '').split('\\n').length;
27530
+ var bpct = lines / 500;
27531
+ var bcolor = bpct >= 1 ? 'var(--red)' : bpct >= 0.8 ? 'var(--yellow)' : 'var(--text-muted)';
27532
+ bodyCounter.textContent = lines + ' / 500 lines (recommended)';
27533
+ bodyCounter.style.color = bcolor;
27534
+ }
27306
27535
  }
27307
27536
 
27308
27537
  function closeSkillModal() {
@@ -27622,11 +27851,54 @@ async function showSkillDetail(name) {
27622
27851
  return;
27623
27852
  }
27624
27853
  detailEl.innerHTML = renderSkillDetail(d.skill);
27854
+ if (typeof loadSkillSuppressionState === 'function') loadSkillSuppressionState(name);
27625
27855
  } catch (e) {
27626
27856
  detailEl.innerHTML = '<div style="padding:24px;color:var(--red);font-size:12px">Error: ' + esc(String(e)) + '</div>';
27627
27857
  }
27628
27858
  }
27629
27859
 
27860
+ // 1.18.127 — fetch the current suppression state and wire the checkbox.
27861
+ // Cached per call; the file is small enough that re-fetching on every
27862
+ // detail open is fine (and ensures consistency if the user just toggled
27863
+ // from another browser tab).
27864
+ async function loadSkillSuppressionState(skillName) {
27865
+ var checkbox = document.getElementById('skill-suppress-global');
27866
+ var status = document.getElementById('skill-suppress-status');
27867
+ if (!checkbox) return;
27868
+ try {
27869
+ var r = await apiFetch('/api/skills/suppressions');
27870
+ var d = await r.json();
27871
+ if (!r.ok || !d || !d.suppressions) return;
27872
+ var globalList = Array.isArray(d.suppressions.global) ? d.suppressions.global : [];
27873
+ checkbox.checked = globalList.indexOf(skillName) !== -1;
27874
+ if (status) {
27875
+ status.textContent = checkbox.checked ? 'Suppressed globally — runtime auto-match will skip this skill.' : '';
27876
+ }
27877
+ } catch (_) { /* non-fatal */ }
27878
+ }
27879
+
27880
+ async function toggleSkillSuppression(skillName, scope, suppressed) {
27881
+ var status = document.getElementById('skill-suppress-status');
27882
+ try {
27883
+ var r = await apiFetch('/api/skills/suppressions/' + encodeURIComponent(skillName), {
27884
+ method: 'PUT',
27885
+ headers: { 'Content-Type': 'application/json' },
27886
+ body: JSON.stringify({ suppressed: !!suppressed, scope: scope }),
27887
+ });
27888
+ var d = await r.json();
27889
+ if (!r.ok) {
27890
+ toast(d.error || 'Failed to update suppression', 'error');
27891
+ return;
27892
+ }
27893
+ if (status) {
27894
+ status.textContent = suppressed ? 'Suppressed globally — runtime auto-match will skip this skill.' : '';
27895
+ }
27896
+ toast(suppressed ? 'Suppressed "' + skillName + '"' : 'Un-suppressed "' + skillName + '"', 'success');
27897
+ } catch (err) {
27898
+ toast('Failed: ' + err, 'error');
27899
+ }
27900
+ }
27901
+
27630
27902
  // ── Skill detail pane ────────────────────────────────────────────────
27631
27903
  // Renders a single skill in the right pane. Sections, in order:
27632
27904
  // 1. Header (name + 3 badges + description + file path)
@@ -27668,6 +27940,17 @@ function renderSkillDetail(s) {
27668
27940
  html += '<p style="font-size:12px;color:var(--text-muted);font-style:italic;margin:0">No description. Anthropic spec recommends adding one so the skill can be discovered by Claude.</p>';
27669
27941
  }
27670
27942
  html += '<div style="margin-top:10px;font-size:11px;color:var(--text-muted);font-family:\\x27JetBrains Mono\\x27,monospace">' + esc(s.filePath) + '</div>';
27943
+ // 1.18.127 — suppression toggle. Lets the user manually hide a skill
27944
+ // from auto-match retrieval without deleting it. Per-scope (global vs
27945
+ // per-agent). Lazy-loaded; rendered as a placeholder, populated by
27946
+ // loadSkillSuppressionState() right after the detail pane mounts.
27947
+ html += '<div id="skill-suppress-row" data-skill="' + esc(fm.name) + '" style="margin-top:14px;padding:10px 12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">';
27948
+ html += '<span style="font-size:12px;color:var(--text-secondary);font-weight:500">Suppress from auto-match:</span>';
27949
+ html += '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);cursor:pointer">';
27950
+ html += '<input type="checkbox" id="skill-suppress-global" onchange="toggleSkillSuppression(\\x27' + jsStr(fm.name) + '\\x27, \\x27global\\x27, this.checked)"> globally';
27951
+ html += '</label>';
27952
+ html += '<span id="skill-suppress-status" style="font-size:11px;color:var(--text-muted);margin-left:auto"></span>';
27953
+ html += '</div>';
27671
27954
  html += '</div>';
27672
27955
 
27673
27956
  // ── 2. Validation warnings (if any)
@@ -28322,13 +28605,50 @@ async function loadSkillsCatalog() {
28322
28605
 
28323
28606
  async function loadMcpCatalog() {
28324
28607
  if (_mcpCatalog) return _mcpCatalog;
28608
+ // 1.18.128 — merge Composio toolkits into the picker. discoverMcpServers()
28609
+ // only sees Claude Desktop / Claude Code / Extensions / user-managed
28610
+ // config, but the runtime ALSO injects every connected Composio toolkit
28611
+ // via buildExtraMcpForRunAgent. The picker was blind to all that — users
28612
+ // would scroll and not see Gmail, Slack, Salesforce, etc., even though
28613
+ // those servers fire correctly when the cron runs. This fixes the picker
28614
+ // to match runtime reality.
28615
+ var servers = [];
28325
28616
  try {
28326
28617
  var r = await apiFetch('/api/mcp-servers');
28327
28618
  var d = await r.json();
28328
- _mcpCatalog = { servers: d.servers || [] };
28329
- } catch {
28330
- _mcpCatalog = { servers: [] };
28331
- }
28619
+ servers = (d.servers || []).map(function(s) {
28620
+ return Object.assign({}, s, { _origin: s.source || 'config' });
28621
+ });
28622
+ } catch (_) { servers = []; }
28623
+ try {
28624
+ var rc = await apiFetch('/api/composio/toolkits');
28625
+ var dc = await rc.json();
28626
+ if (dc && dc.enabled !== false && Array.isArray(dc.toolkits)) {
28627
+ // Only show toolkits with at least one ACTIVE connection — those are
28628
+ // the ones the runtime can actually call. Auth-config-only toolkits
28629
+ // would fail tool calls, so showing them here would mislead.
28630
+ var connected = dc.toolkits.filter(function(t) {
28631
+ return Array.isArray(t.connections) && t.connections.some(function(c) { return c && c.status === 'ACTIVE'; });
28632
+ });
28633
+ var existingNames = new Set(servers.map(function(s) { return s.name; }));
28634
+ for (var i = 0; i < connected.length; i++) {
28635
+ var t = connected[i];
28636
+ if (existingNames.has(t.slug)) continue; // dedup — local config wins
28637
+ servers.push({
28638
+ name: t.slug,
28639
+ type: 'composio',
28640
+ description: t.description || (t.displayName + ' (via Composio)'),
28641
+ enabled: true,
28642
+ source: 'composio',
28643
+ _origin: 'composio',
28644
+ _displayName: t.displayName,
28645
+ _toolCount: t.toolCount,
28646
+ _connectionCount: (t.connections || []).filter(function(c) { return c && c.status === 'ACTIVE'; }).length,
28647
+ });
28648
+ }
28649
+ }
28650
+ } catch (_) { /* Composio not enabled / API down — picker still works */ }
28651
+ _mcpCatalog = { servers: servers };
28332
28652
  return _mcpCatalog;
28333
28653
  }
28334
28654
 
@@ -28373,14 +28693,50 @@ function renderSkillsPickerList() {
28373
28693
  listEl.innerHTML = createRow + skills.slice(0, 50).map(function(s) {
28374
28694
  var sel = _cronSelectedSkills.indexOf(s.name) !== -1;
28375
28695
  var triggers = (s.triggers || []).slice(0, 4).join(', ');
28696
+ // 1.18.127 — pull preview metadata from the rich Skill record so the
28697
+ // picker shows what the user is about to pin: body line count, tool
28698
+ // count, useCount, lastUsed, and a stub warning if the body is < 5
28699
+ // lines (placeholder skill that shouldn't go to a critical task).
28700
+ var fm = s.frontmatter || {};
28701
+ var ext = (fm.clementine || {});
28702
+ var toolsAllow = (ext.tools && Array.isArray(ext.tools.allow)) ? ext.tools.allow : [];
28703
+ var bodyText = String(s.body || '');
28704
+ var bodyLines = bodyText ? bodyText.split('\\n').length : 0;
28705
+ var useCount = typeof ext.useCount === 'number' ? ext.useCount : 0;
28706
+ var lastUsedStr = ext.lastUsed || '';
28707
+ var lastUsedAgo = '';
28708
+ var ageDays = 999;
28709
+ if (lastUsedStr) {
28710
+ var lu = Date.parse(lastUsedStr);
28711
+ if (!isNaN(lu)) {
28712
+ ageDays = Math.floor((Date.now() - lu) / 86400000);
28713
+ lastUsedAgo = ageDays === 0 ? 'today' : ageDays === 1 ? 'yesterday' : ageDays + 'd ago';
28714
+ }
28715
+ }
28716
+ // Health pill: green (>=5 uses, fresh), yellow (untested), red (stale).
28717
+ var health, healthColor, healthLabel;
28718
+ if (useCount === 0) { health = 'untested'; healthColor = 'var(--yellow)'; healthLabel = 'never run'; }
28719
+ else if (useCount < 5) { health = 'untested'; healthColor = 'var(--yellow)'; healthLabel = useCount + 'x · light usage'; }
28720
+ else if (ageDays > 90) { health = 'stale'; healthColor = 'var(--red)'; healthLabel = useCount + 'x · stale (' + lastUsedAgo + ')'; }
28721
+ else { health = 'healthy'; healthColor = 'var(--green)'; healthLabel = useCount + 'x' + (lastUsedAgo ? ' · ' + lastUsedAgo : ''); }
28722
+ var stubWarn = bodyLines > 0 && bodyLines < 5
28723
+ ? '<span style="color:var(--red);font-size:10px;margin-left:6px" title="Body is < 5 lines — likely a placeholder">⚠ stub</span>'
28724
+ : '';
28725
+ var metaPills = '<div class="cap-picker-row-meta" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:4px">'
28726
+ + '<span style="color:var(--text-muted);font-size:11px">' + bodyLines + ' line' + (bodyLines === 1 ? '' : 's') + '</span>'
28727
+ + (toolsAllow.length > 0 ? '<span style="color:var(--text-muted);font-size:11px" title="Tools the skill declares it needs">' + toolsAllow.length + ' tool' + (toolsAllow.length === 1 ? '' : 's') + '</span>' : '')
28728
+ + '<span style="color:' + healthColor + ';font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em" title="Skill health based on useCount + lastUsed">● ' + healthLabel + '</span>'
28729
+ + '</div>';
28376
28730
  return '<div class="cap-picker-row' + (sel ? ' selected' : '') + '" onclick="addSkillToTrick(\\x27' + jsStr(s.name) + '\\x27)">'
28377
28731
  + '<div class="cap-picker-row-body">'
28378
28732
  + '<div class="cap-picker-row-title">' + esc(s.title || s.name) + ' '
28379
28733
  + '<span style="color:var(--text-muted);font-weight:normal;font-size:10px">' + esc(s.name) + '</span>'
28380
28734
  + (sel ? ' <span style="color:var(--accent);font-size:11px">✓ pinned</span>' : '')
28735
+ + stubWarn
28381
28736
  + '</div>'
28382
28737
  + (s.description ? '<div class="cap-picker-row-desc">' + esc(s.description) + '</div>' : '')
28383
- + (triggers ? '<div class="cap-picker-row-meta">triggers: ' + esc(triggers) + (s.triggers && s.triggers.length > 4 ? ' …' : '') + '</div>' : '')
28738
+ + metaPills
28739
+ + (triggers ? '<div class="cap-picker-row-meta" style="margin-top:2px">triggers: ' + esc(triggers) + (s.triggers && s.triggers.length > 4 ? ' …' : '') + '</div>' : '')
28384
28740
  + '</div></div>';
28385
28741
  }).join('');
28386
28742
  }
@@ -28433,13 +28789,26 @@ function renderMcpPickerList() {
28433
28789
  listEl.innerHTML = servers.slice(0, 50).map(function(s) {
28434
28790
  var sel = _cronSelectedMcp.indexOf(s.name) !== -1;
28435
28791
  var enabledTag = s.enabled === false ? ' <span style="color:var(--text-muted);font-size:10px">(disabled)</span>' : '';
28792
+ // 1.18.128 — distinct badge for Composio-sourced toolkits so users can
28793
+ // see at a glance which servers come from local config vs the
28794
+ // Composio account, plus a connection count for managed accounts.
28795
+ var sourceBadge = '';
28796
+ if (s._origin === 'composio') {
28797
+ var connTxt = s._connectionCount ? s._connectionCount + ' conn' : 'connected';
28798
+ sourceBadge = ' <span style="background:rgba(124,58,237,0.12);color:var(--purple);font-size:9px;padding:1px 6px;border-radius:4px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em" title="Sourced from Composio account">composio</span>'
28799
+ + ' <span style="color:var(--text-muted);font-size:10px">' + esc(connTxt) + '</span>';
28800
+ } else if (s.source && s.source !== 'auto-detected') {
28801
+ sourceBadge = ' <span style="background:var(--bg-tertiary);color:var(--text-muted);font-size:9px;padding:1px 6px;border-radius:4px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em">' + esc(s.source) + '</span>';
28802
+ }
28803
+ var displayName = s._displayName || s.name;
28436
28804
  return '<div class="cap-picker-row mcp' + (sel ? ' selected' : '') + '" onclick="addMcpToTrick(\\x27' + jsStr(s.name) + '\\x27)">'
28437
28805
  + '<div class="cap-picker-row-body">'
28438
- + '<div class="cap-picker-row-title">' + esc(s.name) + enabledTag
28806
+ + '<div class="cap-picker-row-title">' + esc(displayName)
28807
+ + (displayName !== s.name ? ' <span style="color:var(--text-muted);font-weight:normal;font-size:10px">' + esc(s.name) + '</span>' : '')
28808
+ + enabledTag + sourceBadge
28439
28809
  + (sel ? ' <span style="color:var(--purple);font-size:11px">✓ allowed</span>' : '')
28440
28810
  + '</div>'
28441
28811
  + (s.description ? '<div class="cap-picker-row-desc">' + esc(s.description) + '</div>' : '')
28442
- + (s.source ? '<div class="cap-picker-row-meta">source: ' + esc(s.source) + '</div>' : '')
28443
28812
  + '</div></div>';
28444
28813
  }).join('');
28445
28814
  }
@@ -29100,6 +29469,31 @@ async function enablePredictableFromBanner() {
29100
29469
  }
29101
29470
  }
29102
29471
 
29472
+ // 1.18.128 — open the cron modal pre-wired to a project. Called from the
29473
+ // "+ New task" button on each project card. Pre-fills cron-workdir and
29474
+ // suggests a name based on the project so the user only has to fill in
29475
+ // the prompt + schedule. The dropdown is populated by refreshProjects()
29476
+ // at page load, so the pre-filled value resolves cleanly to one of the
29477
+ // existing options.
29478
+ function openCronModalForProject(projectIdx) {
29479
+ var p = (typeof projectsData !== 'undefined' && Array.isArray(projectsData)) ? projectsData[projectIdx] : null;
29480
+ if (!p) { toast('Project not found.', 'error'); return; }
29481
+ openCreateCronModal();
29482
+ // Pre-set the project context. dropdown options were populated by
29483
+ // refreshProjects on page load, so the value matches one of them.
29484
+ var sel = document.getElementById('cron-workdir');
29485
+ if (sel) sel.value = p.path;
29486
+ // Suggest a task name based on the project — replaces non-slug chars
29487
+ // and truncates so the slug rule passes. User can override.
29488
+ var slugBase = (p.name || 'project').toLowerCase()
29489
+ .replace(/[^a-z0-9-]+/g, '-')
29490
+ .replace(/^-+|-+$/g, '')
29491
+ .slice(0, 40);
29492
+ var nameInput = document.getElementById('cron-name');
29493
+ if (nameInput && !nameInput.value) nameInput.value = slugBase + '-task';
29494
+ toast('Project Context set to "' + (p.name || p.path) + '" — fill in the prompt and schedule.', 'info');
29495
+ }
29496
+
29103
29497
  function openCreateCronModal(agentSlug) {
29104
29498
  _cronAgentContext = agentSlug || '';
29105
29499
  editingCronJob = null;
@@ -29381,12 +29775,46 @@ function renderCronPreview(d) {
29381
29775
  if (!d.effectiveAllowedTools) {
29382
29776
  html += '<div style="color:var(--text-muted);font-size:12px;font-style:italic">Inheriting from agent profile / SDK default — no per-trick tool restriction.</div>';
29383
29777
  } else {
29384
- html += '<div style="font-family:monospace;font-size:11px;color:var(--text-secondary);line-height:1.6">';
29385
- html += d.effectiveAllowedTools.map(esc).join(', ');
29778
+ // 1.18.127 — attribute each tool to the cron's own allowlist vs a
29779
+ // pinned-skill widening. The widenedFromSkills.tools list contains
29780
+ // entries that were ADDED by a skill on top of the cron's base list.
29781
+ var widenedTools = (d.widenedFromSkills && Array.isArray(d.widenedFromSkills.tools))
29782
+ ? new Set(d.widenedFromSkills.tools) : new Set();
29783
+ html += '<div style="font-family:monospace;font-size:11px;line-height:1.7">';
29784
+ html += d.effectiveAllowedTools.map(function(t) {
29785
+ if (widenedTools.has(t)) {
29786
+ return '<span style="color:var(--purple)" title="Added by a pinned skill">' + esc(t) + ' <span style="font-style:italic;font-size:10px">(from skill)</span></span>';
29787
+ }
29788
+ return '<span style="color:var(--text-secondary)">' + esc(t) + '</span>';
29789
+ }).join(', ');
29386
29790
  html += '</div>';
29791
+ if (widenedTools.size > 0) {
29792
+ html += '<div style="margin-top:8px;padding:8px 10px;border-radius:6px;background:var(--bg-tertiary);font-size:11px;color:var(--text-muted);line-height:1.5">'
29793
+ + '<strong>Pinned-skill widening (1.18.125):</strong> a pinned skill\\'s <code>clementine.tools.allow</code> declaration added '
29794
+ + widenedTools.size + ' tool' + (widenedTools.size === 1 ? '' : 's') + ' on top of this task\\'s base allowlist.'
29795
+ + '</div>';
29796
+ }
29387
29797
  }
29388
29798
  html += '</div>';
29389
29799
 
29800
+ // Widened MCP servers from pinned-skill bodies (1.18.127)
29801
+ // Skills that reference mcp__server__tool tokens in their body implicitly
29802
+ // need that server live; surface those widenings here so the user sees
29803
+ // exactly which MCP servers got pulled in by the skills.
29804
+ if (d.widenedFromSkills && Array.isArray(d.widenedFromSkills.mcpServers) && d.widenedFromSkills.mcpServers.length > 0) {
29805
+ html += '<div class="preview-section">';
29806
+ html += '<h4>MCP servers widened by skill bodies</h4>';
29807
+ html += '<div style="font-size:12px;color:var(--text-secondary);line-height:1.6;margin-bottom:6px">';
29808
+ html += 'These MCP servers got attached because a pinned skill\\'s body references <code>mcp__server__tool</code> tokens.';
29809
+ html += '</div>';
29810
+ html += '<div>';
29811
+ for (var wj = 0; wj < d.widenedFromSkills.mcpServers.length; wj++) {
29812
+ html += '<span class="preview-chip pinned" style="color:var(--purple)" title="Pulled in by skill body MCP reference">' + esc(d.widenedFromSkills.mcpServers[wj]) + '</span>';
29813
+ }
29814
+ html += '</div>';
29815
+ html += '</div>';
29816
+ }
29817
+
29390
29818
  // Built prompt (what the agent literally receives)
29391
29819
  html += '<div class="preview-section">';
29392
29820
  html += '<h4>Built prompt <span style="font-weight:normal;color:var(--text-muted)">(' + d.builtPrompt.length + ' chars — what the agent receives verbatim)</span></h4>';
@@ -34567,6 +34995,119 @@ function openStartUnleashedTask() {
34567
34995
  toast('Open a scheduled task, set Mode to Unleashed, then run it.', 'info');
34568
34996
  }
34569
34997
 
34998
+ // ── MEMORY.md inline editor (1.18.127) ──────────────────────────────
34999
+ // Three globals and four handlers. Globals are intentionally on window
35000
+ // (not module-scoped) so the inline onclick / onchange handlers in the
35001
+ // markup can reach them — this dashboard is one big inline-script SPA.
35002
+ window._memoryMdLoadedMtime = 0;
35003
+ window._memoryMdDirty = false;
35004
+ window._memoryMdSaving = false;
35005
+
35006
+ async function loadMemoryMdEditor() {
35007
+ var scopeEl = document.getElementById('memory-md-scope');
35008
+ var ed = document.getElementById('memory-md-editor');
35009
+ var statusEl = document.getElementById('memory-md-status');
35010
+ var saveBtn = document.getElementById('memory-md-save-btn');
35011
+ var pathEl = document.getElementById('memory-md-path');
35012
+ if (!ed || !statusEl || !saveBtn) return;
35013
+
35014
+ // Populate the scope dropdown once (global + every hired agent).
35015
+ if (scopeEl && scopeEl.options.length <= 1) {
35016
+ try {
35017
+ var ar = await apiFetch('/api/agents');
35018
+ var ad = await ar.json();
35019
+ var agents = (ad && Array.isArray(ad.agents)) ? ad.agents : [];
35020
+ for (var i = 0; i < agents.length; i++) {
35021
+ var slug = agents[i].slug || agents[i].name;
35022
+ if (!slug) continue;
35023
+ var opt = document.createElement('option');
35024
+ opt.value = slug;
35025
+ opt.textContent = (agents[i].name || slug) + ' (' + slug + ')';
35026
+ scopeEl.appendChild(opt);
35027
+ }
35028
+ } catch (_) { /* fallback: global only */ }
35029
+ }
35030
+
35031
+ var scope = scopeEl ? scopeEl.value : 'global';
35032
+ statusEl.textContent = 'Loading…';
35033
+ saveBtn.disabled = true;
35034
+ try {
35035
+ var url = '/api/memory/md' + (scope && scope !== 'global' ? '?agent=' + encodeURIComponent(scope) : '');
35036
+ var r = await apiFetch(url);
35037
+ var d = await r.json();
35038
+ if (!r.ok) {
35039
+ statusEl.textContent = 'Failed to load: ' + (d.error || r.status);
35040
+ return;
35041
+ }
35042
+ ed.value = d.content || '';
35043
+ window._memoryMdLoadedMtime = d.mtimeMs || 0;
35044
+ window._memoryMdDirty = false;
35045
+ if (pathEl) pathEl.textContent = d.filePath || '';
35046
+ var existsMsg = d.exists ? '' : ' (file not yet on disk — will be created on save)';
35047
+ statusEl.textContent = scope === 'global' ? 'Global MEMORY.md loaded' + existsMsg : 'MEMORY.md for ' + scope + ' loaded' + existsMsg;
35048
+ updateMemoryMdCounter();
35049
+ } catch (err) {
35050
+ statusEl.textContent = 'Failed to load: ' + (err && err.message || err);
35051
+ }
35052
+ }
35053
+
35054
+ function updateMemoryMdCounter() {
35055
+ var ed = document.getElementById('memory-md-editor');
35056
+ var counter = document.getElementById('memory-md-counter');
35057
+ if (!ed || !counter) return;
35058
+ var n = (ed.value || '').length;
35059
+ counter.textContent = n.toLocaleString() + ' chars' + (window._memoryMdDirty ? ' · unsaved' : '');
35060
+ }
35061
+
35062
+ function onMemoryMdInput() {
35063
+ window._memoryMdDirty = true;
35064
+ var saveBtn = document.getElementById('memory-md-save-btn');
35065
+ if (saveBtn) saveBtn.disabled = false;
35066
+ updateMemoryMdCounter();
35067
+ }
35068
+
35069
+ async function saveMemoryMd() {
35070
+ if (window._memoryMdSaving) return;
35071
+ var ed = document.getElementById('memory-md-editor');
35072
+ var scopeEl = document.getElementById('memory-md-scope');
35073
+ var statusEl = document.getElementById('memory-md-status');
35074
+ var saveBtn = document.getElementById('memory-md-save-btn');
35075
+ if (!ed || !statusEl || !saveBtn) return;
35076
+ var scope = scopeEl ? scopeEl.value : 'global';
35077
+ window._memoryMdSaving = true;
35078
+ saveBtn.disabled = true;
35079
+ statusEl.textContent = 'Saving…';
35080
+ try {
35081
+ var url = '/api/memory/md' + (scope && scope !== 'global' ? '?agent=' + encodeURIComponent(scope) : '');
35082
+ var r = await apiFetch(url, {
35083
+ method: 'PUT',
35084
+ headers: { 'content-type': 'application/json' },
35085
+ body: JSON.stringify({ content: ed.value, expectedMtimeMs: window._memoryMdLoadedMtime }),
35086
+ });
35087
+ var d = await r.json();
35088
+ if (r.status === 409) {
35089
+ statusEl.textContent = 'Conflict: ' + (d.error || 'file changed on disk');
35090
+ toast('MEMORY.md was modified outside the dashboard. Click Reload to see the latest version.', 'warn');
35091
+ return;
35092
+ }
35093
+ if (!r.ok) {
35094
+ statusEl.textContent = 'Save failed: ' + (d.error || r.status);
35095
+ toast('Save failed: ' + (d.error || r.status), 'error');
35096
+ return;
35097
+ }
35098
+ window._memoryMdLoadedMtime = d.mtimeMs || 0;
35099
+ window._memoryMdDirty = false;
35100
+ statusEl.textContent = 'Saved · ' + new Date().toLocaleTimeString();
35101
+ updateMemoryMdCounter();
35102
+ toast('MEMORY.md saved', 'success');
35103
+ } catch (err) {
35104
+ statusEl.textContent = 'Save failed: ' + (err && err.message || err);
35105
+ toast('Save failed: ' + (err && err.message || err), 'error');
35106
+ } finally {
35107
+ window._memoryMdSaving = false;
35108
+ }
35109
+ }
35110
+
34570
35111
  async function refreshMemoryHealth() {
34571
35112
  var el = document.getElementById('memory-health-content');
34572
35113
  if (!el) return;
@@ -39491,6 +40032,27 @@ try {
39491
40032
  toast('Daemon restarted \u2014 refreshing data...', 'info');
39492
40033
  setTimeout(function() { refreshAll(); }, 1500);
39493
40034
  }
40035
+ // 1.18.127 \u2014 auto-extraction visibility. Surface a small toast when
40036
+ // the background memory extractor writes a fact / note / task /
40037
+ // user_model slot. Respects a localStorage "silent learning" toggle.
40038
+ if (evt.type === 'memory_extracted') {
40039
+ try {
40040
+ if (localStorage.getItem('clem-silent-learning') === '1') return;
40041
+ var d = evt.data || {};
40042
+ var summary = d.summary || '';
40043
+ var label = '';
40044
+ switch (d.toolName) {
40045
+ case 'memory_write': label = '\ud83d\udcdd Noted'; break;
40046
+ case 'note_create': label = '\ud83d\udcc4 Note created'; break;
40047
+ case 'note_take': label = '\ud83d\udcdd Note saved'; break;
40048
+ case 'task_add': label = '\u2705 Task added'; break;
40049
+ case 'user_model': label = '\ud83e\udde0 Updated user model'; break;
40050
+ default: label = '\ud83d\udcdd Memory updated';
40051
+ }
40052
+ var msg = summary ? label + ': ' + summary : label;
40053
+ toast(msg, 'info');
40054
+ } catch (e) { /* non-fatal */ }
40055
+ }
39494
40056
  if (evt.type === 'builder') {
39495
40057
  try { _handleBuilderEvent(evt.data); } catch(e) { /* non-fatal */ }
39496
40058
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.126",
3
+ "version": "1.18.128",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "postinstall": "node scripts/postinstall.js 2>/dev/null || true"
29
29
  },
30
30
  "dependencies": {
31
- "@anthropic-ai/claude-agent-sdk": "^0.2.126",
31
+ "@anthropic-ai/claude-agent-sdk": "^0.2.137",
32
32
  "@anthropic-ai/sdk": "^0.91.0",
33
33
  "@composio/claude-agent-sdk": "^0.8.1",
34
34
  "@composio/core": "^0.8.1",