clementine-agent 1.13.3 → 1.15.0

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/README.md CHANGED
@@ -307,9 +307,6 @@ WORKSPACE_DIRS=~/projects,~/work
307
307
 
308
308
  # Security
309
309
  ALLOW_ALL_USERS=false # true = skip owner checks
310
-
311
- # Beta Features
312
- ENABLE_1M_CONTEXT=false # Enable 1M token context for Sonnet (toggle in dashboard)
313
310
  ```
314
311
 
315
312
  Secrets can also be stored in macOS Keychain (`security find-generic-password`) — Clementine checks Keychain as a fallback for any missing `.env` value.
@@ -336,7 +333,6 @@ Your overrides live in `~/.clementine/.env` — **they survive every `npm update
336
333
  | `BUDGET_CRON_T2_USD` | `5.00` | Max spend per tier-2 cron job |
337
334
  | `BUDGET_HEARTBEAT_USD` | `0.50` | Max spend per heartbeat tick |
338
335
  | `DEFAULT_MODEL_TIER` | `sonnet` | Default model: `haiku` / `sonnet` / `opus` |
339
- | `ENABLE_1M_CONTEXT` | `false` | Enable Sonnet 1M-token context (beta) |
340
336
  | `HEARTBEAT_INTERVAL_MINUTES` | `30` | How often the agent auto-checks in |
341
337
  | `HEARTBEAT_ACTIVE_START` | `8` | First hour of the active window (0–23) |
342
338
  | `HEARTBEAT_ACTIVE_END` | `22` | Last hour of the active window |
@@ -247,7 +247,9 @@ export declare class PersonalAssistant {
247
247
  };
248
248
  delegateProfile?: AgentProfile;
249
249
  }): Promise<string>;
250
- runCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
250
+ runCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, timeoutMs?: number, successCriteria?: string[], agentSlug?: string, opts?: {
251
+ disableAllTools?: boolean;
252
+ }): Promise<string>;
251
253
  /**
252
254
  * Goal-backward verification pass using Haiku after cron job execution.
253
255
  * Instead of vague quality ratings, verifies actual outcomes:
@@ -13,7 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { query as rawQuery, listSubagents, getSubagentMessages, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, } from '@anthropic-ai/claude-agent-sdk';
15
15
  import pino from 'pino';
16
- import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, TASK_BUDGET_TOKENS, ENABLE_1M_CONTEXT, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, } from '../config.js';
16
+ import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, TASK_BUDGET_TOKENS, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, } from '../config.js';
17
17
  import { DEFAULT_CHANNEL_CAPABILITIES } from '../types.js';
18
18
  import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, setProfileTier, setProfileAllowedTools, setAgentDir, setSendPolicy, setInteractionSource, logAuditJsonl, } from './hooks.js';
19
19
  import { scanner } from '../security/scanner.js';
@@ -1958,11 +1958,6 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1958
1958
  // path that wants to log "soft target" values, but it is intentionally
1959
1959
  // never passed into sdkOptions.
1960
1960
  const supportsTaskBudget = false;
1961
- // 1M context beta: enable for Sonnet when toggled and context-heavy work benefits
1962
- const isSonnet = resolvedModel.includes('sonnet');
1963
- const computedBetas = ENABLE_1M_CONTEXT && isSonnet
1964
- ? ['context-1m-2025-08-07']
1965
- : undefined;
1966
1961
  // Merge external MCP servers (Claude Desktop, Claude Code, user-managed).
1967
1962
  // Skip when tools are disabled (no point connecting to servers we won't use)
1968
1963
  // or for internal plan steps that only need Clementine's own tools.
@@ -2036,7 +2031,6 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2036
2031
  ...(computedEffort ? { effort: computedEffort } : {}),
2037
2032
  // maxBudgetUsd intentionally omitted — see comment above.
2038
2033
  ...(computedThinking ? { thinking: computedThinking } : {}),
2039
- ...(computedBetas ? { betas: computedBetas } : {}),
2040
2034
  ...(outputFormat ? { outputFormat } : {}),
2041
2035
  canUseTool: async (toolName, toolInput, _options) => {
2042
2036
  // Per-query stall guard (no global state — scoped to this query)
@@ -3897,9 +3891,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3897
3891
  // ── Heartbeat / Cron ──────────────────────────────────────────────
3898
3892
  async heartbeat(standingInstructions, changesSummary = '', timeContext = '', dedupContext = '', profile) {
3899
3893
  setInteractionSource('autonomous');
3894
+ // Heartbeat speaks text only — the prompt below explicitly forbids tool
3895
+ // calls. Skipping MCP server load + tool inventory cuts the prompt by
3896
+ // hundreds of thousands of tokens on installs with many integrations,
3897
+ // which is what kept Haiku exceeding its 200K context window.
3900
3898
  const sdkOptions = await this.buildOptions({
3901
3899
  isHeartbeat: true,
3902
3900
  enableTeams: false,
3901
+ disableAllTools: true,
3903
3902
  model: MODELS.haiku,
3904
3903
  profile: profile ?? undefined,
3905
3904
  });
@@ -4006,7 +4005,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4006
4005
  return extractDeliverable(trace) ||
4007
4006
  trace.filter(t => t.type === 'text').map(t => t.content).join('').trim();
4008
4007
  }
4009
- async runCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, timeoutMs, successCriteria, agentSlug) {
4008
+ async runCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, timeoutMs, successCriteria, agentSlug, opts) {
4010
4009
  setInteractionSource('autonomous');
4011
4010
  // Tag every tool_use audit event with the cron job name + agent so
4012
4011
  // analytics tool-usage can show "Bash×893 driven by market-leader-followup"
@@ -4040,6 +4039,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4040
4039
  enableTeams: true,
4041
4040
  stallGuard: cronGuard,
4042
4041
  profile: cronProfile,
4042
+ disableAllTools: opts?.disableAllTools ?? false,
4043
4043
  });
4044
4044
  // Override cwd if a project workDir is specified
4045
4045
  if (workDir) {
@@ -318,7 +318,14 @@ export async function classifyRoute(userMessage, agents, gateway) {
318
318
  try {
319
319
  raw = await gateway.handleCronJob('route-classify', prompt, 1, // tier 1
320
320
  3, // maxTurns — classifier doesn't need tools
321
- 'haiku');
321
+ 'haiku', // cheap
322
+ undefined, // workDir
323
+ 'standard', // mode
324
+ undefined, // maxHours
325
+ undefined, // timeoutMs
326
+ undefined, // successCriteria
327
+ undefined, // agentSlug
328
+ { disableAllTools: true });
322
329
  }
323
330
  catch (err) {
324
331
  logger.warn({ err }, 'Route classifier call failed');
@@ -11,7 +11,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, formatCronEmbed, rehydrateStatusEmbed, setSavedStatusEmbed, } from './discord-utils.js';
14
- import { DISCORD_TOKEN, DISCORD_OWNER_ID, DISCORD_WATCHED_CHANNELS, MODELS, ASSISTANT_NAME, OWNER_NAME, PKG_DIR, VAULT_DIR, BASE_DIR, DEFAULT_MODEL_TIER, ENABLE_1M_CONTEXT, } from '../config.js';
14
+ import { DISCORD_TOKEN, DISCORD_OWNER_ID, DISCORD_WATCHED_CHANNELS, MODELS, ASSISTANT_NAME, OWNER_NAME, PKG_DIR, VAULT_DIR, BASE_DIR, DEFAULT_MODEL_TIER, } from '../config.js';
15
15
  import { findProjectByName, getLinkedProjects } from '../agent/assistant.js';
16
16
  import * as cronParser from 'cron-parser';
17
17
  const logger = pino({ name: 'clementine.discord' });
@@ -535,8 +535,7 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
535
535
  embed.addFields({ name: '\u{1F4CB} Scheduled', value: schedSummary, inline: true });
536
536
  // ── System info ──────────────────────────────────────────────
537
537
  const modelLabel = DEFAULT_MODEL_TIER.charAt(0).toUpperCase() + DEFAULT_MODEL_TIER.slice(1);
538
- const contextTag = ENABLE_1M_CONTEXT ? ' \u00b7 1M context' : '';
539
- embed.addFields({ name: '\u{2699}\u{FE0F} System', value: `${modelLabel}${contextTag}`, inline: true });
538
+ embed.addFields({ name: '\u{2699}\u{FE0F} System', value: modelLabel, inline: true });
540
539
  return embed;
541
540
  }
542
541
  /** Format a duration in minutes to a compact human string. */
@@ -234,8 +234,7 @@ function startDaemonWatcher(broadcastFn) {
234
234
  lastKnownDaemonPid = currentPid;
235
235
  }, 5000);
236
236
  }
237
- // ── Memory search (direct DB access, read-only) ─────────────────────
238
- async function searchMemory(query, limit = 20) {
237
+ async function searchMemory(query, limit = 20, filters = {}) {
239
238
  if (!existsSync(MEMORY_DB_PATH)) {
240
239
  return { results: [], dbExists: false, error: `Memory DB not found at ${MEMORY_DB_PATH}` };
241
240
  }
@@ -243,20 +242,39 @@ async function searchMemory(query, limit = 20) {
243
242
  const db = new Database(MEMORY_DB_PATH, { readonly: true });
244
243
  try {
245
244
  const words = query.split(/\s+/).filter((w) => w.length > 0);
246
- if (words.length === 0) {
245
+ const where = ['sd.chunk_id IS NULL'];
246
+ const params = [];
247
+ let orderBy = 'c.updated_at DESC';
248
+ let fromClause = 'chunks c LEFT JOIN chunk_soft_deletes sd ON sd.chunk_id = c.id';
249
+ if (words.length > 0) {
250
+ const ftsQuery = words.map((w) => `"${w.replace(/"/g, '')}"`).join(' OR ');
251
+ fromClause = 'chunks_fts f JOIN chunks c ON c.id = f.rowid LEFT JOIN chunk_soft_deletes sd ON sd.chunk_id = c.id';
252
+ where.unshift('chunks_fts MATCH ?');
253
+ params.push(ftsQuery);
254
+ orderBy = 'bm25(chunks_fts)';
255
+ }
256
+ if (filters.chunkType) {
257
+ where.push('c.chunk_type = ?');
258
+ params.push(filters.chunkType);
259
+ }
260
+ if (filters.sinceDays && filters.sinceDays > 0) {
261
+ where.push("c.updated_at >= datetime('now', ?)");
262
+ params.push(`-${filters.sinceDays} days`);
263
+ }
264
+ if (filters.pinnedOnly) {
265
+ where.push('c.pinned = 1');
266
+ }
267
+ if (words.length === 0 && !filters.chunkType && !filters.sinceDays && !filters.pinnedOnly) {
247
268
  db.close();
248
269
  return { results: [], dbExists: true };
249
270
  }
250
- const ftsQuery = words.map((w) => `"${w.replace(/"/g, '')}"`).join(' OR ');
251
- const rows = db.prepare(`SELECT c.id, c.source_file, c.section, c.content, c.chunk_type,
252
- c.updated_at, c.salience, c.pinned, bm25(chunks_fts) as score
253
- FROM chunks_fts f
254
- JOIN chunks c ON c.id = f.rowid
255
- LEFT JOIN chunk_soft_deletes sd ON sd.chunk_id = c.id
256
- WHERE chunks_fts MATCH ?
257
- AND sd.chunk_id IS NULL
258
- ORDER BY bm25(chunks_fts)
259
- LIMIT ?`).all(ftsQuery, limit);
271
+ const sql = `SELECT c.id, c.source_file, c.section, c.content, c.chunk_type,
272
+ c.updated_at, c.salience, c.pinned${words.length > 0 ? ', bm25(chunks_fts) as score' : ', 0 as score'}
273
+ FROM ${fromClause}
274
+ WHERE ${where.join(' AND ')}
275
+ ORDER BY ${orderBy}
276
+ LIMIT ?`;
277
+ const rows = db.prepare(sql).all(...params, limit);
260
278
  return { results: rows, dbExists: true };
261
279
  }
262
280
  catch (err) {
@@ -5142,7 +5160,6 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5142
5160
  label: 'Model',
5143
5161
  keys: [
5144
5162
  { key: 'DEFAULT_MODEL_TIER', label: 'Default Tier', hint: 'haiku, sonnet, or opus', type: 'select:haiku,sonnet,opus' },
5145
- { key: 'ENABLE_1M_CONTEXT', label: '1M Context', hint: 'Enable 1M token context window for Sonnet (beta)', type: 'toggle' },
5146
5163
  ],
5147
5164
  },
5148
5165
  {
@@ -5340,10 +5357,6 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5340
5357
  }
5341
5358
  writeEnvValue(key, value);
5342
5359
  // Apply runtime-hot settings immediately (no restart needed)
5343
- if (key === 'ENABLE_1M_CONTEXT') {
5344
- const { setEnable1MContext } = await import('../config.js');
5345
- setEnable1MContext(value.toLowerCase() === 'true');
5346
- }
5347
5360
  // Composio: mutate process.env in-place + drop singleton so the very
5348
5361
  // next /api/composio/* call picks up the new key without a daemon
5349
5362
  // restart. Without this, "Save key → Connect Gmail" would 503 until
@@ -5747,6 +5760,57 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5747
5760
  res.status(500).json({ error: String(err) });
5748
5761
  }
5749
5762
  });
5763
+ // Quick-add: append a sentence to today's daily note from the dashboard.
5764
+ // Mirrors the agent's memory_write({action:'append_daily'}) path so the
5765
+ // note gets indexed identically.
5766
+ app.post('/api/memory/quick-add', async (req, res) => {
5767
+ try {
5768
+ const content = String(req.body?.content ?? '').trim();
5769
+ const section = (req.body?.section ? String(req.body.section) : 'Interactions').trim() || 'Interactions';
5770
+ const salienceHint = req.body?.salience_hint != null ? Number(req.body.salience_hint) : undefined;
5771
+ if (!content) {
5772
+ res.status(400).json({ error: 'content required' });
5773
+ return;
5774
+ }
5775
+ if (content.length > 4000) {
5776
+ res.status(400).json({ error: 'content too long (max 4000 chars)' });
5777
+ return;
5778
+ }
5779
+ const sharedMod = await import('../tools/shared.js');
5780
+ const dailyPath = sharedMod.ensureDailyNote();
5781
+ const timestamp = sharedMod.nowTime();
5782
+ let body = readFileSync(dailyPath, 'utf-8');
5783
+ const entry = `\n- **${timestamp}** — ${content}`;
5784
+ const pattern = new RegExp(`(## ${section.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}.*?)(\\n## |$)`, 's');
5785
+ const match = pattern.exec(body);
5786
+ if (match) {
5787
+ body = body.slice(0, match.index + match[1].length) + entry + body.slice(match.index + match[1].length);
5788
+ }
5789
+ else {
5790
+ body += `\n\n## ${section}${entry}`;
5791
+ }
5792
+ writeFileSync(dailyPath, body, 'utf-8');
5793
+ const rel = path.relative(VAULT_DIR, dailyPath);
5794
+ await sharedMod.incrementalSync(rel);
5795
+ try {
5796
+ const store = await sharedMod.getStore();
5797
+ if (typeof salienceHint === 'number' && Number.isFinite(salienceHint)) {
5798
+ store.applyWriteSalience(rel, section, salienceHint);
5799
+ }
5800
+ store.logExtraction({
5801
+ sessionKey: 'dashboard:quick-add', userMessage: content.slice(0, 200),
5802
+ toolName: 'memory_write',
5803
+ toolInput: JSON.stringify({ action: 'append_daily', section, salience_hint: salienceHint, source: 'dashboard' }),
5804
+ extractedAt: new Date().toISOString(), status: 'active',
5805
+ });
5806
+ }
5807
+ catch { /* observability best-effort */ }
5808
+ res.json({ ok: true, file: rel, section });
5809
+ }
5810
+ catch (err) {
5811
+ res.status(500).json({ error: String(err) });
5812
+ }
5813
+ });
5750
5814
  app.post('/api/memory/health/action', async (req, res) => {
5751
5815
  try {
5752
5816
  const action = (req.body?.action ?? '');
@@ -6250,12 +6314,15 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6250
6314
  // ── Memory search route ───────────────────────────────────────────
6251
6315
  app.get('/api/memory/search', async (req, res) => {
6252
6316
  const q = String(req.query.q ?? '');
6253
- if (!q.trim()) {
6317
+ const chunkType = req.query.type ? String(req.query.type) : undefined;
6318
+ const sinceDays = req.query.since ? Number(req.query.since) : undefined;
6319
+ const pinnedOnly = String(req.query.pinned ?? '') === 'true';
6320
+ if (!q.trim() && !chunkType && !sinceDays && !pinnedOnly) {
6254
6321
  res.json({ results: [] });
6255
6322
  return;
6256
6323
  }
6257
6324
  try {
6258
- const data = await searchMemory(q, 20);
6325
+ const data = await searchMemory(q, 20, { chunkType, sinceDays, pinnedOnly });
6259
6326
  // Enrich with graph relationships for entities found in results
6260
6327
  let graphContext = [];
6261
6328
  try {
@@ -12918,9 +12985,38 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12918
12985
  <h1>Brain</h1>
12919
12986
  <p class="desc">Query what you know, feed new knowledge in, and watch the system learn.</p>
12920
12987
  </div>
12921
- <div class="actions" style="flex:1;max-width:480px;display:flex;gap:8px">
12988
+ <div class="actions" style="flex:1;max-width:560px;display:flex;gap:8px">
12922
12989
  <input type="text" id="memory-search-input" placeholder="Search vault, notes, memory..." style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px" onkeydown="if(event.key==='Enter')runMemorySearch()">
12923
12990
  <button class="btn-primary btn-sm" onclick="runMemorySearch()">Search</button>
12991
+ <button class="btn-sm" onclick="openQuickAddMemory()" title="Append a quick note to today's daily log">+ Add memory</button>
12992
+ </div>
12993
+ </div>
12994
+
12995
+ <!-- Quick-add memory modal: type a sentence, hit save. Posts to memory_write
12996
+ append_daily so the note lands in today's vault log and gets indexed. -->
12997
+ <div id="quick-add-memory-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center" onclick="if(event.target===this)closeQuickAddMemory()">
12998
+ <div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:10px;padding:20px;width:520px;max-width:90vw">
12999
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
13000
+ <h3 style="margin:0;font-size:16px">Add a memory</h3>
13001
+ <button class="btn-icon btn-sm" onclick="closeQuickAddMemory()" title="Close">×</button>
13002
+ </div>
13003
+ <div style="font-size:12px;color:var(--text-muted);margin-bottom:10px">
13004
+ Appends to today's daily note. The agent will see this on its next search.
13005
+ </div>
13006
+ <textarea id="quick-add-memory-text" placeholder="What should Clementine remember?" rows="5" style="width:100%;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;font-family:inherit;resize:vertical" autofocus></textarea>
13007
+ <div style="display:flex;gap:8px;align-items:center;margin-top:10px">
13008
+ <label style="font-size:12px;color:var(--text-muted)">Salience:</label>
13009
+ <select id="quick-add-memory-salience" style="padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:12px">
13010
+ <option value="0.5">0.5 — tentative</option>
13011
+ <option value="1.0" selected>1.0 — normal</option>
13012
+ <option value="1.5">1.5 — durable</option>
13013
+ <option value="2.0">2.0 — identity-level</option>
13014
+ </select>
13015
+ <span style="flex:1"></span>
13016
+ <span id="quick-add-memory-status" style="font-size:12px;color:var(--text-muted)"></span>
13017
+ <button class="btn-sm" onclick="closeQuickAddMemory()">Cancel</button>
13018
+ <button class="btn-primary btn-sm" onclick="submitQuickAddMemory()">Save</button>
13019
+ </div>
12924
13020
  </div>
12925
13021
  </div>
12926
13022
  <div class="tab-bar" id="intelligence-tabs" style="margin:0 0 0 18px">
@@ -12931,13 +13027,41 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12931
13027
  <button data-icon="zap" onclick="switchTab('intelligence','health')"><span class="icon-slot"></span> Health <span class="tab-badge" id="brain-health-badge" style="display:none;background:#ef4444;color:#fff">0</span></button>
12932
13028
  <button data-icon="users" onclick="switchTab('intelligence','user-model')"><span class="icon-slot"></span> User Model</button>
12933
13029
  <button data-icon="brain" onclick="switchTab('intelligence','learning')"><span class="icon-slot"></span> Learning <span class="tab-badge" id="brain-learning-badge" style="display:none;background:#f59e0b;color:#000">0</span></button>
12934
- <button onclick="switchTab('intelligence','memory')">Stats</button>
12935
13030
  <button onclick="switchTab('intelligence','seed')">Seed</button>
12936
13031
  <button onclick="switchTab('intelligence','runs')">Runs</button>
12937
13032
  </div>
12938
13033
  <div id="intelligence-tab-content">
12939
13034
  <div class="tab-pane active" id="tab-intelligence-search">
13035
+ <div id="memory-coverage-strip" style="margin-bottom:14px"></div>
12940
13036
  <div id="memory-search-results"></div>
13037
+ <div id="memory-overview" style="margin-top:18px">
13038
+ <div class="grid-2" id="memory-stats" style="margin-bottom:14px"></div>
13039
+ <div class="card" style="margin-bottom:14px">
13040
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
13041
+ <span>MEMORY.md</span>
13042
+ <span style="font-size:11px;color:var(--text-muted)">Curated facts loaded into every session</span>
13043
+ </div>
13044
+ <div class="card-body" id="panel-memory"><div class="empty-state">Loading...</div></div>
13045
+ </div>
13046
+ <div class="card" style="margin-bottom:14px">
13047
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
13048
+ <span>Recent writes</span>
13049
+ <span style="font-size:11px;color:var(--text-muted)">What the agent captured, with reason &amp; salience</span>
13050
+ </div>
13051
+ <div class="card-body" id="panel-recent-writes" style="padding:0">
13052
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
13053
+ </div>
13054
+ </div>
13055
+ <div class="card">
13056
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
13057
+ <span>Self-correction (supersedes)</span>
13058
+ <span style="font-size:11px;color:var(--text-muted)">Old facts the agent has explicitly replaced</span>
13059
+ </div>
13060
+ <div class="card-body" id="panel-supersedes" style="padding:0">
13061
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
13062
+ </div>
13063
+ </div>
13064
+ </div>
12941
13065
  </div>
12942
13066
  <div class="tab-pane" id="tab-intelligence-graph">
12943
13067
  <div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
@@ -12956,18 +13080,6 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12956
13080
  <div id="graph-legend" style="display:flex;gap:16px;margin-top:8px;flex-wrap:wrap"></div>
12957
13081
  <div id="graph-detail-panel" style="margin-top:12px"></div>
12958
13082
  </div>
12959
- <div class="tab-pane" id="tab-intelligence-memory">
12960
- <div style="margin-bottom:12px;font-size:13px;color:var(--text-muted)">
12961
- Stats and content browsing. For janitor, integrity, write queue, and staleness diagnostics see
12962
- <a href="#" onclick="navigateTo('memory-health');return false" style="color:var(--accent)">Memory Health &rarr;</a>
12963
- </div>
12964
- <div class="grid-2" id="memory-stats"></div>
12965
- <div class="card">
12966
- <div class="card-header">MEMORY.md</div>
12967
- <div class="card-body" id="panel-memory"><div class="empty-state">Loading...</div></div>
12968
- </div>
12969
- </div>
12970
-
12971
13083
  <!-- User Model — MemGPT-style core memory blocks always loaded into context -->
12972
13084
  <div class="tab-pane" id="tab-intelligence-user-model">
12973
13085
  <div style="color:var(--muted,#888);margin-bottom:12px;font-size:13px;max-width:760px">
@@ -13165,24 +13277,6 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
13165
13277
  <div id="memory-health-content">
13166
13278
  <div class="skel-block"><div class="skel-row med"></div><div class="skel-row"></div><div class="skel-row short"></div></div>
13167
13279
  </div>
13168
- <div class="card" style="margin-top:18px">
13169
- <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
13170
- <span>Recent writes</span>
13171
- <span style="font-size:11px;color:var(--text-muted)">What the agent captured, with reason &amp; salience</span>
13172
- </div>
13173
- <div class="card-body" id="panel-recent-writes" style="padding:0">
13174
- <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
13175
- </div>
13176
- </div>
13177
- <div class="card" style="margin-top:18px">
13178
- <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
13179
- <span>Self-correction (supersedes)</span>
13180
- <span style="font-size:11px;color:var(--text-muted)">Old facts the agent has explicitly replaced</span>
13181
- </div>
13182
- <div class="card-body" id="panel-supersedes" style="padding:0">
13183
- <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
13184
- </div>
13185
- </div>
13186
13280
  <div class="card" style="margin-top:18px">
13187
13281
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
13188
13282
  <span>Knowledge graph signals</span>
@@ -15505,7 +15599,6 @@ function navigateTo(page, opts) {
15505
15599
  }
15506
15600
  break;
15507
15601
  case 'brain':
15508
- if (typeof refreshMemory === 'function') refreshMemory();
15509
15602
  var bt = opts.tab || 'memory';
15510
15603
  // Spec tab names → internal intelligence-tab ids
15511
15604
  var intelTab = bt === 'memory' ? 'search'
@@ -15516,16 +15609,7 @@ function navigateTo(page, opts) {
15516
15609
  : bt === 'learning' ? 'learning'
15517
15610
  : bt;
15518
15611
  try { switchTab('intelligence', intelTab); } catch (e) { /* */ }
15519
- if (bt === 'health') {
15520
- if (typeof refreshMemoryHealth === 'function') refreshMemoryHealth();
15521
- if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
15522
- if (typeof refreshSupersedes === 'function') refreshSupersedes();
15523
- if (typeof refreshGraphStats === 'function') refreshGraphStats();
15524
- if (typeof refreshSessionBridge === 'function') refreshSessionBridge();
15525
- if (typeof refreshClaims === 'function') refreshClaims();
15526
- if (typeof refreshRoutingAudit === 'function') refreshRoutingAudit();
15527
- }
15528
- if (bt === 'learning' && typeof refreshSelfImprove === 'function') refreshSelfImprove();
15612
+ // switchTab() above already fires the per-tab refreshers; nothing to do here.
15529
15613
  break;
15530
15614
  case 'settings':
15531
15615
  // Settings tabs use the switchTab() system (id="tab-settings-<tab>"),
@@ -15923,12 +16007,16 @@ function switchTab(group, tab) {
15923
16007
  // Tab-specific refresh
15924
16008
  if (group === 'intelligence') {
15925
16009
  if (tab === 'graph') refreshGraph();
15926
- if (tab === 'memory') refreshMemory();
16010
+ if (tab === 'search') {
16011
+ // Consolidated Memory tab: search results + stats + MEMORY.md + recent writes + supersedes + coverage strip.
16012
+ refreshMemory();
16013
+ if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
16014
+ if (typeof refreshSupersedes === 'function') refreshSupersedes();
16015
+ if (typeof refreshCoverageStrip === 'function') refreshCoverageStrip();
16016
+ }
15927
16017
  if (tab === 'files' && typeof refreshVaultFiles === 'function') refreshVaultFiles();
15928
16018
  if (tab === 'health') {
15929
16019
  if (typeof refreshMemoryHealth === 'function') refreshMemoryHealth();
15930
- if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
15931
- if (typeof refreshSupersedes === 'function') refreshSupersedes();
15932
16020
  if (typeof refreshGraphStats === 'function') refreshGraphStats();
15933
16021
  if (typeof refreshSessionBridge === 'function') refreshSessionBridge();
15934
16022
  if (typeof refreshClaims === 'function') refreshClaims();
@@ -21105,16 +21193,39 @@ async function loadBuilderAttachments(jobName) {
21105
21193
  }
21106
21194
 
21107
21195
  // ── Memory Search ─────────────────────────
21196
+ // Parse inline filter syntax from a query string. Supported:
21197
+ // type:procedure → ?type=procedure
21198
+ // since:7d / since:30d → ?since=7
21199
+ // pinned:true → ?pinned=true
21200
+ // Returns the cleaned query (filters stripped) plus the filter params.
21201
+ function parseSearchFilters(raw) {
21202
+ var filters = {};
21203
+ var cleaned = raw.replace(/\b(type|since|pinned):(\S+)/g, function(_m, key, val) {
21204
+ if (key === 'type') filters.type = val;
21205
+ else if (key === 'since') {
21206
+ var m = /^(\d+)d?$/.exec(val);
21207
+ if (m) filters.since = m[1];
21208
+ }
21209
+ else if (key === 'pinned' && val === 'true') filters.pinned = 'true';
21210
+ return '';
21211
+ }).replace(/\s+/g, ' ').trim();
21212
+ return { q: cleaned, filters: filters };
21213
+ }
21214
+
21108
21215
  async function runMemorySearch() {
21109
21216
  const input = document.getElementById('memory-search-input');
21110
- const q = input.value.trim();
21111
- if (!q) return;
21217
+ const raw = input.value.trim();
21218
+ if (!raw) return;
21112
21219
 
21220
+ const parsed = parseSearchFilters(raw);
21113
21221
  const container = document.getElementById('memory-search-results');
21114
21222
  container.innerHTML = '<div class="empty-state">Searching...</div>';
21115
21223
 
21116
21224
  try {
21117
- const r = await apiFetch('/api/memory/search?q=' + encodeURIComponent(q));
21225
+ var qs = 'q=' + encodeURIComponent(parsed.q);
21226
+ var fkeys = Object.keys(parsed.filters);
21227
+ for (var i = 0; i < fkeys.length; i++) qs += '&' + fkeys[i] + '=' + encodeURIComponent(parsed.filters[fkeys[i]]);
21228
+ const r = await apiFetch('/api/memory/search?' + qs);
21118
21229
  const d = await r.json();
21119
21230
 
21120
21231
  if (d.error) {
@@ -21126,11 +21237,16 @@ async function runMemorySearch() {
21126
21237
  }
21127
21238
 
21128
21239
  if (!d.results || d.results.length === 0) {
21129
- container.innerHTML = '<div class="empty-state">No results found for "' + esc(q) + '"</div>';
21240
+ container.innerHTML = '<div class="empty-state">No results found for "' + esc(raw) + '"</div>';
21130
21241
  return;
21131
21242
  }
21132
21243
 
21133
- let html = '<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px">' + d.results.length + ' result(s)</div>';
21244
+ var filterChips = '';
21245
+ var fkeys2 = Object.keys(parsed.filters);
21246
+ for (var fi = 0; fi < fkeys2.length; fi++) {
21247
+ filterChips += '<span style="display:inline-block;padding:2px 8px;margin-right:4px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:10px;font-size:11px">' + esc(fkeys2[fi]) + ': ' + esc(parsed.filters[fkeys2[fi]]) + '</span>';
21248
+ }
21249
+ let html = '<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px">' + d.results.length + ' result(s)' + (filterChips ? ' &middot; ' + filterChips : '') + '</div>';
21134
21250
 
21135
21251
  // Show graph relationships if any
21136
21252
  if (d.graphContext && d.graphContext.length > 0) {
@@ -21149,20 +21265,24 @@ async function runMemorySearch() {
21149
21265
  const score = Math.abs(r.score || 0).toFixed(2);
21150
21266
  const pinned = r.pinned ? ' 📌' : '';
21151
21267
  const idAttr = r.id ? String(r.id) : '';
21268
+ const previewSnippet = (r.content || '').slice(0, 200).replace(/\s+/g, ' ').trim();
21152
21269
  html += '<div class="search-result" data-chunk-id="' + esc(idAttr) + '" id="chunk-row-' + esc(idAttr) + '">'
21153
21270
  + '<div class="search-result-header" style="display:flex;justify-content:space-between;align-items:center">'
21154
21271
  + '<span class="search-result-file">' + esc(r.source_file) + pinned + '</span>'
21155
- + '<div style="display:flex;gap:6px;align-items:center">'
21272
+ + '<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">'
21156
21273
  + '<span class="search-result-score" style="font-size:11px;color:var(--text-muted)">score ' + score + '</span>'
21157
21274
  + (idAttr ? (
21158
21275
  '<button class="btn" style="font-size:11px;padding:2px 8px" onclick="editChunk(' + idAttr + ')">Edit</button>'
21159
21276
  + '<button class="btn" style="font-size:11px;padding:2px 8px" onclick="togglePinChunk(' + idAttr + ',' + (r.pinned ? 'false' : 'true') + ')">' + (r.pinned ? 'Unpin' : 'Pin') + '</button>'
21160
21277
  + '<button class="btn" style="font-size:11px;padding:2px 8px;color:var(--red,#ef4444)" onclick="deleteChunk(' + idAttr + ')">Delete</button>'
21278
+ + '<button class="btn" style="font-size:11px;padding:2px 8px" data-snippet="' + esc(previewSnippet) + '" onclick="findSimilarFromButton(this)" title="Search using this chunk\\'s content">Find similar</button>'
21279
+ + '<button class="btn" style="font-size:11px;padding:2px 8px" onclick="toggleTrace(' + idAttr + ')" id="trace-toggle-' + idAttr + '">Trace ▾</button>'
21161
21280
  ) : '')
21162
21281
  + '</div>'
21163
21282
  + '</div>'
21164
21283
  + '<div class="search-result-section">' + esc(r.section || '') + ' &middot; ' + esc(r.chunk_type || '') + '</div>'
21165
21284
  + '<div class="search-result-content" id="chunk-content-' + esc(idAttr) + '">' + esc((r.content || '').slice(0, 500)) + '</div>'
21285
+ + (idAttr ? '<div id="trace-' + idAttr + '" style="display:none;margin-top:8px;padding:10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;font-size:12px"></div>' : '')
21166
21286
  + '</div>';
21167
21287
  }
21168
21288
  container.innerHTML = html;
@@ -21328,6 +21448,156 @@ async function refreshSessionBridge() {
21328
21448
  }
21329
21449
  }
21330
21450
 
21451
+ // Re-run search using the selected chunk's content as the query. With dense
21452
+ // embeddings populated, this becomes a true semantic "more like this" lookup.
21453
+ // Reads the snippet from the button's data-snippet attribute to avoid HTML/JS
21454
+ // escaping issues with quotes inside chunk content.
21455
+ function findSimilarFromButton(btn) {
21456
+ var snippet = (btn && btn.dataset && btn.dataset.snippet) ? btn.dataset.snippet : '';
21457
+ if (!snippet) return;
21458
+ var input = document.getElementById('memory-search-input');
21459
+ if (!input) return;
21460
+ input.value = snippet;
21461
+ runMemorySearch();
21462
+ }
21463
+
21464
+ // Toggle the inline Trace disclosure on a search result. Loads chunk metadata
21465
+ // and edit/supersede history from /api/memory/chunks/:id and /history.
21466
+ async function toggleTrace(id) {
21467
+ var box = document.getElementById('trace-' + id);
21468
+ var btn = document.getElementById('trace-toggle-' + id);
21469
+ if (!box) return;
21470
+ if (box.style.display !== 'none') {
21471
+ box.style.display = 'none';
21472
+ if (btn) btn.textContent = 'Trace ▾';
21473
+ return;
21474
+ }
21475
+ box.style.display = 'block';
21476
+ if (btn) btn.textContent = 'Trace ▴';
21477
+ box.innerHTML = '<div style="color:var(--text-muted)">Loading…</div>';
21478
+ try {
21479
+ var chunkResp = await apiFetch('/api/memory/chunks/' + id);
21480
+ var chunkData = await chunkResp.json();
21481
+ var historyResp = await apiFetch('/api/memory/chunks/' + id + '/history');
21482
+ var historyData = await historyResp.json();
21483
+ if (!chunkData.ok || !chunkData.chunk) {
21484
+ box.innerHTML = '<div style="color:#ef4444">' + esc(chunkData.error || 'Failed to load') + '</div>';
21485
+ return;
21486
+ }
21487
+ var c = chunkData.chunk;
21488
+ var meta = '<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin-bottom:8px">'
21489
+ + '<span style="color:var(--text-muted)">ID:</span><span>' + esc(String(c.id || id)) + '</span>'
21490
+ + '<span style="color:var(--text-muted)">Source:</span><span>' + esc(c.sourceFile || c.source_file || '—') + '</span>'
21491
+ + '<span style="color:var(--text-muted)">Section:</span><span>' + esc(c.section || '—') + '</span>'
21492
+ + '<span style="color:var(--text-muted)">Type:</span><span>' + esc(c.chunkType || c.chunk_type || '—') + '</span>'
21493
+ + '<span style="color:var(--text-muted)">Salience:</span><span>' + esc(String(c.salience != null ? Number(c.salience).toFixed(2) : '—')) + (c.pinned ? ' (pinned)' : '') + '</span>'
21494
+ + '<span style="color:var(--text-muted)">Confidence:</span><span>' + esc(String(c.confidence != null ? Number(c.confidence).toFixed(2) : '—')) + '</span>'
21495
+ + '<span style="color:var(--text-muted)">Created:</span><span>' + esc(c.createdAt || c.created_at || '—') + '</span>'
21496
+ + '<span style="color:var(--text-muted)">Updated:</span><span>' + esc(c.lastUpdated || c.updated_at || '—') + '</span>'
21497
+ + '<span style="color:var(--text-muted)">Agent:</span><span>' + esc(c.agentSlug || c.agent_slug || 'global') + '</span>'
21498
+ + '</div>';
21499
+ var history = '';
21500
+ if (historyData.ok && Array.isArray(historyData.history) && historyData.history.length > 0) {
21501
+ history += '<div style="margin-top:8px"><b style="font-size:11px;color:var(--text-muted)">HISTORY</b>';
21502
+ for (var i = 0; i < historyData.history.length; i++) {
21503
+ var h = historyData.history[i];
21504
+ history += '<div style="padding:4px 0;border-top:1px solid var(--border);font-size:11px">'
21505
+ + esc(h.timestamp || h.at || '') + ' &middot; ' + esc(h.kind || h.action || 'edit')
21506
+ + (h.reason ? ' &middot; ' + esc(h.reason) : '')
21507
+ + '</div>';
21508
+ }
21509
+ history += '</div>';
21510
+ }
21511
+ box.innerHTML = meta + history;
21512
+ } catch (err) {
21513
+ box.innerHTML = '<div style="color:#ef4444">' + esc(String(err)) + '</div>';
21514
+ }
21515
+ }
21516
+
21517
+ // Quick-add memory modal — type a sentence, hit save, lands in today's daily note.
21518
+ function openQuickAddMemory() {
21519
+ var m = document.getElementById('quick-add-memory-modal');
21520
+ if (!m) return;
21521
+ m.style.display = 'flex';
21522
+ var ta = document.getElementById('quick-add-memory-text');
21523
+ if (ta) { ta.value = ''; setTimeout(function(){ ta.focus(); }, 50); }
21524
+ var st = document.getElementById('quick-add-memory-status');
21525
+ if (st) st.textContent = '';
21526
+ }
21527
+
21528
+ function closeQuickAddMemory() {
21529
+ var m = document.getElementById('quick-add-memory-modal');
21530
+ if (m) m.style.display = 'none';
21531
+ }
21532
+
21533
+ async function submitQuickAddMemory() {
21534
+ var ta = document.getElementById('quick-add-memory-text');
21535
+ var sel = document.getElementById('quick-add-memory-salience');
21536
+ var st = document.getElementById('quick-add-memory-status');
21537
+ if (!ta) return;
21538
+ var content = ta.value.trim();
21539
+ if (!content) { if (st) { st.textContent = 'Type something first'; st.style.color = '#ef4444'; } return; }
21540
+ if (st) { st.textContent = 'Saving…'; st.style.color = 'var(--text-muted)'; }
21541
+ try {
21542
+ var r = await apiFetch('/api/memory/quick-add', {
21543
+ method: 'POST',
21544
+ headers: { 'Content-Type': 'application/json' },
21545
+ body: JSON.stringify({ content: content, salience_hint: sel ? Number(sel.value) : 1.0 }),
21546
+ });
21547
+ var d = await r.json();
21548
+ if (!r.ok || !d.ok) { if (st) { st.textContent = d.error || 'Failed'; st.style.color = '#ef4444'; } return; }
21549
+ if (st) { st.textContent = 'Saved to ' + d.file; st.style.color = '#10b981'; }
21550
+ setTimeout(function() {
21551
+ closeQuickAddMemory();
21552
+ if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
21553
+ if (typeof refreshMemory === 'function') refreshMemory();
21554
+ }, 600);
21555
+ } catch (err) {
21556
+ if (st) { st.textContent = String(err); st.style.color = '#ef4444'; }
21557
+ }
21558
+ }
21559
+
21560
+ // Compact coverage strip at the top of the Memory tab. Shows BM25 (always
21561
+ // available), sparse TF-IDF coverage, and dense neural coverage with a
21562
+ // one-click backfill when coverage is incomplete. Pulls from /api/memory/health.
21563
+ async function refreshCoverageStrip() {
21564
+ var el = document.getElementById('memory-coverage-strip');
21565
+ if (!el) return;
21566
+ try {
21567
+ var r = await apiFetch('/api/memory/health');
21568
+ var d = await r.json();
21569
+ if (!d.ok || !d.health) { el.innerHTML = ''; return; }
21570
+ var h = d.health;
21571
+ var total = (h.chunks && h.chunks.total) || 0;
21572
+ var de = h.denseEmbeddings || { withDense: 0, total: total, currentModel: '', ready: false };
21573
+ var sparseCovered = (h.chunks && h.chunks.withSparseEmbedding != null) ? h.chunks.withSparseEmbedding : null;
21574
+ var densePct = de.total > 0 ? Math.round((de.withDense / de.total) * 100) : 0;
21575
+ var sparsePct = (sparseCovered != null && total > 0) ? Math.round((sparseCovered / total) * 100) : null;
21576
+ var denseColor = densePct >= 95 ? '#10b981' : densePct >= 50 ? '#f59e0b' : '#ef4444';
21577
+ var modelLabel = de.currentModel ? de.currentModel.split('/').pop() : '';
21578
+ var html = '<div style="display:flex;align-items:center;gap:14px;padding:10px 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;flex-wrap:wrap;font-size:12px">';
21579
+ html += '<span style="color:var(--text-muted)">Coverage:</span>';
21580
+ html += '<span><span style="color:#10b981">●</span> BM25 ' + total.toLocaleString() + '</span>';
21581
+ if (sparsePct != null) html += '<span><span style="color:#10b981">●</span> Sparse ' + sparsePct + '%</span>';
21582
+ html += '<span><span style="color:' + denseColor + '">●</span> Dense ' + densePct + '%'
21583
+ + (modelLabel ? ' <span style="color:var(--text-muted)">(' + esc(modelLabel) + ')</span>' : '') + '</span>';
21584
+ if (de.total > 0 && de.withDense < de.total) {
21585
+ var missing = de.total - de.withDense;
21586
+ html += '<span style="margin-left:auto;display:flex;gap:6px">'
21587
+ + '<span style="color:var(--text-muted)">' + missing.toLocaleString() + ' missing</span>'
21588
+ + '<button class="btn-sm" onclick="memoryHealthAction(\\'reembed-dense\\', { limit: 200 })">Backfill 200</button>'
21589
+ + '<button class="btn-sm" onclick="memoryHealthAction(\\'reembed-dense\\', { limit: 2000 })">Backfill 2000</button>'
21590
+ + '</span>';
21591
+ } else if (de.total > 0) {
21592
+ html += '<span style="margin-left:auto;color:var(--text-muted)">All chunks indexed</span>';
21593
+ }
21594
+ html += '</div>';
21595
+ el.innerHTML = html;
21596
+ } catch (err) {
21597
+ el.innerHTML = '';
21598
+ }
21599
+ }
21600
+
21331
21601
  async function refreshRecentWrites() {
21332
21602
  var el = document.getElementById('panel-recent-writes');
21333
21603
  if (!el) return;
@@ -24,7 +24,7 @@ const SPECS = [
24
24
  { key: 'DEFAULT_MODEL_TIER', group: 'models', jsonPath: 'models.default', default: 'sonnet' },
25
25
  { key: 'HAIKU_MODEL', group: 'models', jsonPath: 'models.haiku', default: 'claude-haiku-4-5-20251001' },
26
26
  { key: 'SONNET_MODEL', group: 'models', jsonPath: 'models.sonnet', default: 'claude-sonnet-4-6' },
27
- { key: 'OPUS_MODEL', group: 'models', jsonPath: 'models.opus', default: 'claude-opus-4-6' },
27
+ { key: 'OPUS_MODEL', group: 'models', jsonPath: 'models.opus', default: 'claude-opus-4-7' },
28
28
  // Budgets
29
29
  { key: 'BUDGET_HEARTBEAT_USD', group: 'budgets', jsonPath: 'budgets.heartbeat', default: 0.50 },
30
30
  { key: 'BUDGET_CRON_T1_USD', group: 'budgets', jsonPath: 'budgets.cronT1', default: 2.00 },
package/dist/config.d.ts CHANGED
@@ -84,10 +84,6 @@ export declare const TASK_BUDGET_TOKENS: {
84
84
  };
85
85
  export declare const DEFAULT_MODEL_TIER: keyof Models;
86
86
  export declare const MODEL: string;
87
- /** Enable 1M context window for Sonnet (beta). Toggle via ENABLE_1M_CONTEXT=true in .env or dashboard. */
88
- export declare let ENABLE_1M_CONTEXT: boolean;
89
- /** Update 1M context flag at runtime (called from dashboard settings API). */
90
- export declare function setEnable1MContext(value: boolean): void;
91
87
  export declare const DISCORD_TOKEN: string;
92
88
  export declare const DISCORD_OWNER_ID: string;
93
89
  export declare const DISCORD_WATCHED_CHANNELS: string[];
package/dist/config.js CHANGED
@@ -179,7 +179,7 @@ function getSecret(envKey, keychainService) {
179
179
  export const MODELS = {
180
180
  haiku: getEnvOrJson('HAIKU_MODEL', json.models?.haiku, 'claude-haiku-4-5-20251001'),
181
181
  sonnet: getEnvOrJson('SONNET_MODEL', json.models?.sonnet, 'claude-sonnet-4-6'),
182
- opus: getEnvOrJson('OPUS_MODEL', json.models?.opus, 'claude-opus-4-6'),
182
+ opus: getEnvOrJson('OPUS_MODEL', json.models?.opus, 'claude-opus-4-7'),
183
183
  };
184
184
  // ── Budget caps (USD per query) ──────────────────────────────────────
185
185
  // User-tunable via `clementine config set BUDGET_<NAME>_USD <value>`
@@ -236,12 +236,6 @@ export const TASK_BUDGET_TOKENS = {
236
236
  };
237
237
  export const DEFAULT_MODEL_TIER = (getEnvOrJson('DEFAULT_MODEL_TIER', json.models?.default, 'sonnet'));
238
238
  export const MODEL = MODELS[DEFAULT_MODEL_TIER] ?? MODELS.sonnet;
239
- /** Enable 1M context window for Sonnet (beta). Toggle via ENABLE_1M_CONTEXT=true in .env or dashboard. */
240
- export let ENABLE_1M_CONTEXT = getEnv('ENABLE_1M_CONTEXT', 'false').toLowerCase() === 'true';
241
- /** Update 1M context flag at runtime (called from dashboard settings API). */
242
- export function setEnable1MContext(value) {
243
- ENABLE_1M_CONTEXT = value;
244
- }
245
239
  // ── Discord ──────────────────────────────────────────────────────────
246
240
  export const DISCORD_TOKEN = getSecret('DISCORD_TOKEN');
247
241
  export const DISCORD_OWNER_ID = getEnv('DISCORD_OWNER_ID', '0');
@@ -135,7 +135,13 @@ export async function gradeRun(entry, gateway, jobPrompt) {
135
135
  try {
136
136
  raw = await gateway.handleCronJob(`grade:${entry.jobName}`, prompt, 1, // tier 1
137
137
  3, // maxTurns — tight
138
- 'haiku');
138
+ 'haiku', undefined, // workDir
139
+ 'standard', // mode
140
+ undefined, // maxHours
141
+ undefined, // timeoutMs
142
+ undefined, // successCriteria
143
+ undefined, // agentSlug
144
+ { disableAllTools: true });
139
145
  }
140
146
  catch (err) {
141
147
  logger.warn({ err, jobName: entry.jobName }, 'Outcome grader LLM call failed');
@@ -152,7 +152,9 @@ export declare class Gateway {
152
152
  handleMessage(sessionKey: string, text: string, onText?: OnTextCallback, model?: string, maxTurns?: number, onToolActivity?: OnToolActivityCallback, onProgress?: OnProgressCallback): Promise<string>;
153
153
  private _handleMessageInner;
154
154
  handleHeartbeat(standingInstructions: string, changesSummary?: string, timeContext?: string, dedupContext?: string, profile?: import('../types.js').AgentProfile | null): Promise<string>;
155
- handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
155
+ handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string, opts?: {
156
+ disableAllTools?: boolean;
157
+ }): Promise<string>;
156
158
  /**
157
159
  * Process a team message as an autonomous task — same multi-phase execution
158
160
  * as cron unleashed jobs, so agents can work until done instead of being
@@ -1345,7 +1345,7 @@ export class Gateway {
1345
1345
  releaseLane();
1346
1346
  }
1347
1347
  }
1348
- async handleCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, mode = 'standard', maxHours, timeoutMs, successCriteria, agentSlug) {
1348
+ async handleCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, mode = 'standard', maxHours, timeoutMs, successCriteria, agentSlug, opts) {
1349
1349
  const releaseLane = await lanes.acquire('cron');
1350
1350
  try {
1351
1351
  logger.info(`Running cron job: ${jobName}${workDir ? ` in ${workDir}` : ''}${mode === 'unleashed' ? ' (unleashed)' : ''}${agentSlug && agentSlug !== 'clementine' ? ` as ${agentSlug}` : ''}`);
@@ -1357,7 +1357,7 @@ export class Gateway {
1357
1357
  response = await this.assistant.runUnleashedTask(jobName, jobPrompt, tier, maxTurns, model, workDir, maxHours, agentSlug);
1358
1358
  }
1359
1359
  else {
1360
- response = await this.assistant.runCronJob(jobName, jobPrompt, tier, maxTurns, model, workDir, timeoutMs, successCriteria, agentSlug);
1360
+ response = await this.assistant.runCronJob(jobName, jobPrompt, tier, maxTurns, model, workDir, timeoutMs, successCriteria, agentSlug, opts);
1361
1361
  }
1362
1362
  // Re-baseline integrity checksums after cron job (may write to vault)
1363
1363
  scanner.refreshIntegrity();
package/dist/index.js CHANGED
@@ -247,8 +247,6 @@ function printBanner(channels, profiles, cronJobs, graphEnabled = false) {
247
247
  const modelColor = modelColors[modelName] ?? CYAN;
248
248
  // Feature tags
249
249
  const tags = [];
250
- if (config.ENABLE_1M_CONTEXT)
251
- tags.push('1M context');
252
250
  if (config.GROQ_API_KEY)
253
251
  tags.push('voice');
254
252
  if (config.GOOGLE_API_KEY)
@@ -694,6 +692,24 @@ async function asyncMain() {
694
692
  const heartbeat = new HeartbeatScheduler(gateway, dispatcher);
695
693
  const cronScheduler = new CronScheduler(gateway, dispatcher);
696
694
  heartbeat.setCronScheduler(cronScheduler);
695
+ // Warm the dense embedding model in the background at boot so the first
696
+ // search/backfill doesn't pay the load cost. Failure here is non-fatal —
697
+ // search degrades to BM25 + sparse TF-IDF.
698
+ void (async () => {
699
+ try {
700
+ const embeddings = await import('./memory/embeddings.js');
701
+ const ready = await embeddings.probeDenseReady();
702
+ if (ready) {
703
+ logger.info('Dense embedding model warmed at boot');
704
+ }
705
+ else {
706
+ logger.warn('Dense embedding model failed to warm — search will use BM25 + sparse TF-IDF only');
707
+ }
708
+ }
709
+ catch (err) {
710
+ logger.warn({ err }, 'Dense embedding warmup threw');
711
+ }
712
+ })();
697
713
  // Builder runner — wire MCP invoke handler so canvas test runs can hit
698
714
  // real read-only MCP tools (gmail.list_unread, github.list_prs, etc.).
699
715
  // Stdio clients are pooled per server with idle teardown.
@@ -8,10 +8,11 @@
8
8
  * chunks.embedding (BLOB).
9
9
  *
10
10
  * 2. Dense neural (preferred when available, async). Uses
11
- * `@xenova/transformers` to run a local sentence-embedding model
12
- * (default: Snowflake/snowflake-arctic-embed-m-v1.5, 768-dim) entirely
13
- * on-device. Stored in chunks.embedding_dense (BLOB) with
14
- * chunks.embedding_dense_model tracking which model produced it.
11
+ * `@huggingface/transformers` (v4+) to run a local sentence-embedding
12
+ * model (default: Snowflake/snowflake-arctic-embed-m-v1.5, 768-dim)
13
+ * entirely on-device via onnxruntime-node. Stored in
14
+ * chunks.embedding_dense (BLOB) with chunks.embedding_dense_model
15
+ * tracking which model produced it.
15
16
  *
16
17
  * Runtime behavior:
17
18
  * - At store insert time, sync TF-IDF is computed (cheap, no I/O).
@@ -8,10 +8,11 @@
8
8
  * chunks.embedding (BLOB).
9
9
  *
10
10
  * 2. Dense neural (preferred when available, async). Uses
11
- * `@xenova/transformers` to run a local sentence-embedding model
12
- * (default: Snowflake/snowflake-arctic-embed-m-v1.5, 768-dim) entirely
13
- * on-device. Stored in chunks.embedding_dense (BLOB) with
14
- * chunks.embedding_dense_model tracking which model produced it.
11
+ * `@huggingface/transformers` (v4+) to run a local sentence-embedding
12
+ * model (default: Snowflake/snowflake-arctic-embed-m-v1.5, 768-dim)
13
+ * entirely on-device via onnxruntime-node. Stored in
14
+ * chunks.embedding_dense (BLOB) with chunks.embedding_dense_model
15
+ * tracking which model produced it.
15
16
  *
16
17
  * Runtime behavior:
17
18
  * - At store insert time, sync TF-IDF is computed (cheap, no I/O).
@@ -219,12 +220,16 @@ async function getDensePipeline() {
219
220
  mkdirSync(MODEL_CACHE_DIR, { recursive: true });
220
221
  }
221
222
  catch { /* non-fatal */ }
222
- const transformers = (await import('@xenova/transformers'));
223
+ const transformers = (await import('@huggingface/transformers'));
223
224
  transformers.env.cacheDir = MODEL_CACHE_DIR;
224
225
  transformers.env.allowLocalModels = true;
226
+ transformers.env.allowRemoteModels = true;
225
227
  const modelId = getDenseModelId();
226
- logger.info({ modelId, cacheDir: MODEL_CACHE_DIR }, 'Loading dense embedding model (first use downloads ~440MB)');
227
- const pipe = await transformers.pipeline('feature-extraction', modelId);
228
+ // dtype: 'q8' uses the int8-quantized ONNX file (model_quantized.onnx,
229
+ // ~110MB) instead of the fp32 file (~440MB). Quality loss is negligible
230
+ // for retrieval and load is dramatically faster on CPU.
231
+ logger.info({ modelId, cacheDir: MODEL_CACHE_DIR, dtype: 'q8' }, 'Loading dense embedding model');
232
+ const pipe = await transformers.pipeline('feature-extraction', modelId, { dtype: 'q8' });
228
233
  denseLoadState = true;
229
234
  logger.info({ modelId }, 'Dense embedding model loaded');
230
235
  return pipe;
@@ -180,51 +180,6 @@ export function registerMemoryTools(server) {
180
180
  }
181
181
  }
182
182
  });
183
- // ── 0b. team_scratchpad ────────────────────────────────────────────────
184
- //
185
- // Cross-agent shared scratchpad. Unlike working_memory (per-agent), this
186
- // is a single shared markdown file every agent can read and append to.
187
- // Use cases: live coordination ("Sasha is drafting the brief, Ross hold
188
- // outbound for 30m"), cross-agent context drops, async hand-offs that
189
- // don't warrant a full goal_create or task_add. Append tags every entry
190
- // with the author's agent slug + ISO timestamp so the trail stays clear.
191
- const TEAM_SCRATCHPAD_FILE = path.join(BASE_DIR, 'team-scratchpad.md');
192
- server.tool('team_scratchpad', getToolDescription('team_scratchpad') ?? 'Cross-agent shared scratchpad for live team coordination. All agents read/write the same file. Use for hand-offs, "I am working on X", short-term context drops. For durable facts, use memory_write/MEMORY.md instead.', {
193
- action: z.enum(['read', 'append', 'replace', 'clear']).describe('What to do with the team scratchpad'),
194
- content: z.string().optional().describe('Text to append or replace with (required for append/replace)'),
195
- }, async ({ action, content }) => {
196
- const author = ACTIVE_AGENT_SLUG ?? 'clementine';
197
- switch (action) {
198
- case 'read': {
199
- if (!existsSync(TEAM_SCRATCHPAD_FILE)) {
200
- return textResult('Team scratchpad is empty.');
201
- }
202
- return textResult(readFileSync(TEAM_SCRATCHPAD_FILE, 'utf-8'));
203
- }
204
- case 'append': {
205
- if (!content)
206
- return textResult('Error: content is required for append.');
207
- const stamp = new Date().toISOString();
208
- const entry = `\n- **[${author}@${stamp}]** ${content}\n`;
209
- const existing = existsSync(TEAM_SCRATCHPAD_FILE) ? readFileSync(TEAM_SCRATCHPAD_FILE, 'utf-8') : '# Team Scratchpad\n\nShared across all agents. Append tags entries with author + timestamp.\n';
210
- writeFileSync(TEAM_SCRATCHPAD_FILE, existing + entry);
211
- return textResult(`Appended to team scratchpad as ${author}.`);
212
- }
213
- case 'replace': {
214
- if (!content)
215
- return textResult('Error: content is required for replace.');
216
- const stamp = new Date().toISOString();
217
- const header = `# Team Scratchpad\n\n_Replaced by ${author} at ${stamp}._\n\n`;
218
- writeFileSync(TEAM_SCRATCHPAD_FILE, header + content + '\n');
219
- return textResult(`Team scratchpad replaced by ${author}.`);
220
- }
221
- case 'clear': {
222
- if (existsSync(TEAM_SCRATCHPAD_FILE))
223
- unlinkSync(TEAM_SCRATCHPAD_FILE);
224
- return textResult('Team scratchpad cleared.');
225
- }
226
- }
227
- });
228
183
  // ── 1. memory_read ─────────────────────────────────────────────────────
229
184
  server.tool('memory_read', getToolDescription('memory_read') ?? "Read a note from the Obsidian vault. Shortcuts: 'today', 'yesterday', 'memory', 'tasks', 'heartbeat', 'cron', 'soul'. Or pass a relative path or note name.", {
230
185
  name: z.string().describe('Note name, path, or shortcut'),
@@ -507,43 +462,6 @@ export function registerMemoryTools(server) {
507
462
  }
508
463
  return textResult(`Unknown action: ${action}`);
509
464
  });
510
- // ── 2b. memory_record_procedure ────────────────────────────────────────
511
- server.tool('memory_record_procedure', getToolDescription('memory_record_procedure') ?? 'Record a learned workflow as a durable procedure. Use when you notice a repeating multi-step task ("how Nate ships a release", "how to handle inbound replies"). Stored under 00-System/procedures/ with category=procedure and trigger verbs that surface it later. Different from memory_write/MEMORY.md: those store facts, this stores reusable HOW-TO. From Mem0\'s 2026 procedural-memory pattern.', {
512
- title: z.string().describe('Short procedure title (becomes filename slug)'),
513
- steps: z.string().describe('Numbered steps or markdown body describing how to perform the task'),
514
- triggers: z.array(z.string()).min(1).describe('Verb phrases (e.g. ["ship release", "publish to npm"]) that should surface this procedure when the user query contains them. Lowercase preferred.'),
515
- notes: z.string().optional().describe('Optional context: when to use, when NOT to use, gotchas'),
516
- }, async ({ title, steps, triggers, notes }) => {
517
- const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
518
- if (!slug)
519
- return textResult('Error: title must contain alphanumerics');
520
- const proceduresDir = path.join(SYSTEM_DIR, 'procedures');
521
- mkdirSync(proceduresDir, { recursive: true });
522
- const filePath = path.join(proceduresDir, `${slug}.md`);
523
- const triggersYaml = triggers.map((t) => ` - ${JSON.stringify(t.toLowerCase())}`).join('\n');
524
- const body = [
525
- '---',
526
- `title: ${JSON.stringify(title)}`,
527
- 'category: procedure',
528
- 'triggers:',
529
- triggersYaml,
530
- `created_at: ${new Date().toISOString()}`,
531
- ACTIVE_AGENT_SLUG ? `agent_slug: ${JSON.stringify(ACTIVE_AGENT_SLUG)}` : '',
532
- '---',
533
- '',
534
- `# ${title}`,
535
- '',
536
- '## Steps',
537
- '',
538
- steps.trim(),
539
- '',
540
- ...(notes ? ['## Notes', '', notes.trim(), ''] : []),
541
- ].filter((line) => line !== '').join('\n') + '\n';
542
- writeFileSync(filePath, body, 'utf-8');
543
- const rel = path.relative(VAULT_DIR, filePath);
544
- await incrementalSync(rel, ACTIVE_AGENT_SLUG ?? undefined);
545
- return textResult(`Recorded procedure: ${rel} (triggers: ${triggers.join(', ')})`);
546
- });
547
465
  // ── 3. memory_search ───────────────────────────────────────────────────
548
466
  server.tool('memory_search', getToolDescription('memory_search') ?? 'FTS5 search across all vault notes. Returns matching chunks with relevance scores. Optional category/topic filters narrow results.', {
549
467
  query: z.string().describe('Search text'),
@@ -11,15 +11,10 @@
11
11
  const TOOL_META = {
12
12
  // ── Memory & Vault ────────────────────────────────────────────────
13
13
  working_memory: {
14
- description: 'Per-agent persistent scratchpad — only YOU see it. Survives across conversations. Use for current project context, TODOs, reminders, or anything you need to remember for next time. Actions: read, append, replace, clear. ALWAYS read before replacing. For cross-agent coordination, use team_scratchpad instead.',
14
+ description: 'Per-agent persistent scratchpad — only YOU see it. Survives across conversations. Use for current project context, TODOs, reminders, or anything you need to remember for next time. Actions: read, append, replace, clear. ALWAYS read before replacing.',
15
15
  exampleUsage: 'Before starting complex work, read working_memory to check for context from prior sessions.',
16
16
  returnHint: 'Full working memory contents (markdown text).',
17
17
  },
18
- team_scratchpad: {
19
- description: 'Cross-agent shared scratchpad — every agent on the team reads and writes the same file. Use for live coordination, hand-offs, "I am working on X — back off until Y", short-lived context drops. For durable facts that should outlive coordination noise, use memory_write to MEMORY.md instead. Append tags entries with author slug + timestamp.',
20
- exampleUsage: 'Before starting outbound work, read team_scratchpad to see if another agent has already claimed a prospect or paused outreach.',
21
- returnHint: 'Full scratchpad contents with per-entry author + ISO timestamp.',
22
- },
23
18
  memory_search: {
24
19
  description: 'Full-text search across all vault notes. Best for finding specific keywords or phrases. For broader semantic matching, use memory_recall instead. Results include file path, section heading, and relevance score.',
25
20
  exampleUsage: 'Use when the user asks "what did we discuss about X" or you need to find a specific note.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.13.3",
3
+ "version": "1.15.0",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,15 +21,15 @@
21
21
  "postinstall": "node scripts/postinstall.js 2>/dev/null || true"
22
22
  },
23
23
  "dependencies": {
24
- "@anthropic-ai/claude-agent-sdk": "^0.2.119",
24
+ "@anthropic-ai/claude-agent-sdk": "^0.2.126",
25
25
  "@anthropic-ai/sdk": "^0.91.0",
26
26
  "@composio/claude-agent-sdk": "^0.8.1",
27
27
  "@composio/core": "^0.8.1",
28
+ "@huggingface/transformers": "^4.2.0",
28
29
  "@inquirer/prompts": "^7.0.0",
29
30
  "@modelcontextprotocol/sdk": "^1.29.0",
30
31
  "@slack/bolt": "^4.2.0",
31
32
  "@types/multer": "^2.1.0",
32
- "@xenova/transformers": "^2.17.2",
33
33
  "better-sqlite3": "^11.7.0",
34
34
  "commander": "^13.1.0",
35
35
  "cron-parser": "^5.5.0",