clementine-agent 1.1.30 → 1.2.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.
@@ -249,10 +249,12 @@ async function searchMemory(query, limit = 20) {
249
249
  }
250
250
  const ftsQuery = words.map((w) => `"${w.replace(/"/g, '')}"`).join(' OR ');
251
251
  const rows = db.prepare(`SELECT c.id, c.source_file, c.section, c.content, c.chunk_type,
252
- c.updated_at, c.salience, bm25(chunks_fts) as score
252
+ c.updated_at, c.salience, c.pinned, bm25(chunks_fts) as score
253
253
  FROM chunks_fts f
254
254
  JOIN chunks c ON c.id = f.rowid
255
+ LEFT JOIN chunk_soft_deletes sd ON sd.chunk_id = c.id
255
256
  WHERE chunks_fts MATCH ?
257
+ AND sd.chunk_id IS NULL
256
258
  ORDER BY bm25(chunks_fts)
257
259
  LIMIT ?`).all(ftsQuery, limit);
258
260
  return { results: rows, dbExists: true };
@@ -4323,7 +4325,292 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4323
4325
  // could still end up in browser history, screenshots, etc.
4324
4326
  const { redactSecrets } = await import('../security/redact.js');
4325
4327
  const { text: redacted } = redactSecrets(response ?? '');
4326
- res.json({ ok: true, response: redacted });
4328
+ // Attach the recall trace that powered this answer (if memory was used).
4329
+ // Lets the chat UI render a "🧠 N sources" affordance on each assistant
4330
+ // message, expandable to the chunks retrieved + their scores.
4331
+ let trace = null;
4332
+ try {
4333
+ const store = gateway.assistant?.memoryStore;
4334
+ if (store?.getRecentRecallTraces) {
4335
+ const traces = store.getRecentRecallTraces('dashboard:web', 1);
4336
+ if (traces.length > 0) {
4337
+ trace = {
4338
+ id: traces[0].id,
4339
+ query: traces[0].query,
4340
+ retrievedAt: traces[0].retrievedAt,
4341
+ chunkCount: traces[0].chunkIds.length,
4342
+ };
4343
+ }
4344
+ }
4345
+ }
4346
+ catch { /* trace is optional */ }
4347
+ res.json({ ok: true, response: redacted, trace });
4348
+ }
4349
+ catch (err) {
4350
+ res.status(500).json({ error: String(err) });
4351
+ }
4352
+ });
4353
+ // ── Recall trace expansion (per-message memory provenance) ─────────
4354
+ // Returns the chunks that were retrieved for a specific trace, with their
4355
+ // scores and provenance (source_file, section, derived_from for summaries).
4356
+ app.get('/api/recall-traces/:id', async (req, res) => {
4357
+ try {
4358
+ const id = Number(req.params.id);
4359
+ if (!Number.isFinite(id) || id <= 0) {
4360
+ res.status(400).json({ error: 'invalid trace id' });
4361
+ return;
4362
+ }
4363
+ const gateway = await getGateway();
4364
+ const store = gateway.assistant?.memoryStore;
4365
+ if (!store?.getRecallTrace) {
4366
+ res.status(503).json({ error: 'Memory store not available' });
4367
+ return;
4368
+ }
4369
+ const trace = store.getRecallTrace(id);
4370
+ if (!trace) {
4371
+ res.status(404).json({ error: 'trace not found' });
4372
+ return;
4373
+ }
4374
+ res.json({ ok: true, trace });
4375
+ }
4376
+ catch (err) {
4377
+ res.status(500).json({ error: String(err) });
4378
+ }
4379
+ });
4380
+ // List recent recall traces for a session (default: dashboard:web).
4381
+ // Powers a future "Memory Trace" tab on session detail; also useful for
4382
+ // CLI inspection.
4383
+ app.get('/api/recall-traces', async (req, res) => {
4384
+ try {
4385
+ const sessionKey = String(req.query.sessionKey ?? 'dashboard:web');
4386
+ const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 200);
4387
+ const gateway = await getGateway();
4388
+ const store = gateway.assistant?.memoryStore;
4389
+ if (!store?.getRecentRecallTraces) {
4390
+ res.json({ traces: [] });
4391
+ return;
4392
+ }
4393
+ const traces = store.getRecentRecallTraces(sessionKey, limit);
4394
+ res.json({ ok: true, sessionKey, traces });
4395
+ }
4396
+ catch (err) {
4397
+ res.status(500).json({ error: String(err) });
4398
+ }
4399
+ });
4400
+ // ── User mental model (MemGPT-style core memory) ─────────────────
4401
+ // Always-in-context surface. Slots: user_facts, goals, relationships,
4402
+ // agent_persona. agent_slug=null is the global block; per-agent overrides
4403
+ // layer on top in agent context.
4404
+ app.get('/api/user-model', async (req, res) => {
4405
+ try {
4406
+ const agentSlug = req.query.agentSlug ? String(req.query.agentSlug) : null;
4407
+ const gateway = await getGateway();
4408
+ const store = gateway.assistant?.memoryStore;
4409
+ if (!store?.getAllUserModelBlocks) {
4410
+ res.status(503).json({ error: 'Memory store not available' });
4411
+ return;
4412
+ }
4413
+ const blocks = store.getAllUserModelBlocks(agentSlug);
4414
+ const slots = ['user_facts', 'goals', 'relationships', 'agent_persona'];
4415
+ // Always return a row for each slot (empty if unset) so the UI can render
4416
+ // editable textareas without special-casing missing slots.
4417
+ const merged = slots.map((slot) => {
4418
+ const found = blocks.find((b) => b.slot === slot);
4419
+ return found ?? {
4420
+ slot,
4421
+ content: '',
4422
+ charLimit: 2000,
4423
+ agentSlug: null,
4424
+ updatedAt: null,
4425
+ };
4426
+ });
4427
+ res.json({ ok: true, agentSlug, blocks: merged });
4428
+ }
4429
+ catch (err) {
4430
+ res.status(500).json({ error: String(err) });
4431
+ }
4432
+ });
4433
+ app.put('/api/user-model/:slot', async (req, res) => {
4434
+ try {
4435
+ const slot = String(req.params.slot);
4436
+ const validSlots = ['user_facts', 'goals', 'relationships', 'agent_persona'];
4437
+ if (!validSlots.includes(slot)) {
4438
+ res.status(400).json({ error: 'invalid slot' });
4439
+ return;
4440
+ }
4441
+ const content = String(req.body?.content ?? '');
4442
+ const agentSlug = req.body?.agentSlug ? String(req.body.agentSlug) : null;
4443
+ const gateway = await getGateway();
4444
+ const store = gateway.assistant?.memoryStore;
4445
+ if (!store?.setUserModelBlock) {
4446
+ res.status(503).json({ error: 'Memory store not available' });
4447
+ return;
4448
+ }
4449
+ const result = store.setUserModelBlock({ slot, content, agentSlug });
4450
+ res.json({ ok: true, ...result });
4451
+ }
4452
+ catch (err) {
4453
+ res.status(500).json({ error: String(err) });
4454
+ }
4455
+ });
4456
+ app.delete('/api/user-model/:slot', async (req, res) => {
4457
+ try {
4458
+ const slot = String(req.params.slot);
4459
+ const agentSlug = req.query.agentSlug ? String(req.query.agentSlug) : null;
4460
+ const gateway = await getGateway();
4461
+ const store = gateway.assistant?.memoryStore;
4462
+ if (!store?.deleteUserModelBlock) {
4463
+ res.status(503).json({ error: 'Memory store not available' });
4464
+ return;
4465
+ }
4466
+ const removed = store.deleteUserModelBlock(slot, agentSlug);
4467
+ res.json({ ok: true, removed });
4468
+ }
4469
+ catch (err) {
4470
+ res.status(500).json({ error: String(err) });
4471
+ }
4472
+ });
4473
+ // ── Memory chunk CRUD (dashboard curation) ───────────────────────
4474
+ // Lets the user fix wrong/stale memory directly from the search panel
4475
+ // instead of having to wait for auto-extraction to drift in the right
4476
+ // direction. Soft-delete via deleted_at; FTS trigger keeps deleted
4477
+ // content out of search results.
4478
+ app.get('/api/memory/chunks/:id', async (req, res) => {
4479
+ try {
4480
+ const id = Number(req.params.id);
4481
+ if (!Number.isFinite(id) || id <= 0) {
4482
+ res.status(400).json({ error: 'invalid chunk id' });
4483
+ return;
4484
+ }
4485
+ const gateway = await getGateway();
4486
+ const store = gateway.assistant?.memoryStore;
4487
+ if (!store?.getChunkDetail) {
4488
+ res.status(503).json({ error: 'Memory store not available' });
4489
+ return;
4490
+ }
4491
+ const chunk = store.getChunkDetail(id);
4492
+ if (!chunk) {
4493
+ res.status(404).json({ error: 'chunk not found' });
4494
+ return;
4495
+ }
4496
+ res.json({ ok: true, chunk });
4497
+ }
4498
+ catch (err) {
4499
+ res.status(500).json({ error: String(err) });
4500
+ }
4501
+ });
4502
+ app.put('/api/memory/chunks/:id', async (req, res) => {
4503
+ try {
4504
+ const id = Number(req.params.id);
4505
+ if (!Number.isFinite(id) || id <= 0) {
4506
+ res.status(400).json({ error: 'invalid chunk id' });
4507
+ return;
4508
+ }
4509
+ const body = (req.body ?? {});
4510
+ const gateway = await getGateway();
4511
+ const store = gateway.assistant?.memoryStore;
4512
+ if (!store?.updateChunkContent) {
4513
+ res.status(503).json({ error: 'Memory store not available' });
4514
+ return;
4515
+ }
4516
+ const opts = { chunkId: id, editedBy: 'dashboard' };
4517
+ if (typeof body.content === 'string')
4518
+ opts.content = body.content;
4519
+ if (typeof body.section === 'string')
4520
+ opts.section = body.section;
4521
+ if (body.category === null || typeof body.category === 'string')
4522
+ opts.category = body.category;
4523
+ if (body.topic === null || typeof body.topic === 'string')
4524
+ opts.topic = body.topic;
4525
+ const ok = store.updateChunkContent(opts);
4526
+ if (!ok) {
4527
+ res.status(404).json({ error: 'chunk not found' });
4528
+ return;
4529
+ }
4530
+ const chunk = store.getChunkDetail(id);
4531
+ res.json({ ok: true, chunk });
4532
+ }
4533
+ catch (err) {
4534
+ res.status(500).json({ error: String(err) });
4535
+ }
4536
+ });
4537
+ app.delete('/api/memory/chunks/:id', async (req, res) => {
4538
+ try {
4539
+ const id = Number(req.params.id);
4540
+ if (!Number.isFinite(id) || id <= 0) {
4541
+ res.status(400).json({ error: 'invalid chunk id' });
4542
+ return;
4543
+ }
4544
+ const gateway = await getGateway();
4545
+ const store = gateway.assistant?.memoryStore;
4546
+ if (!store?.softDeleteChunk) {
4547
+ res.status(503).json({ error: 'Memory store not available' });
4548
+ return;
4549
+ }
4550
+ const removed = store.softDeleteChunk(id);
4551
+ res.json({ ok: true, removed });
4552
+ }
4553
+ catch (err) {
4554
+ res.status(500).json({ error: String(err) });
4555
+ }
4556
+ });
4557
+ app.post('/api/memory/chunks/:id/restore', async (req, res) => {
4558
+ try {
4559
+ const id = Number(req.params.id);
4560
+ if (!Number.isFinite(id) || id <= 0) {
4561
+ res.status(400).json({ error: 'invalid chunk id' });
4562
+ return;
4563
+ }
4564
+ const gateway = await getGateway();
4565
+ const store = gateway.assistant?.memoryStore;
4566
+ if (!store?.restoreChunk) {
4567
+ res.status(503).json({ error: 'Memory store not available' });
4568
+ return;
4569
+ }
4570
+ const restored = store.restoreChunk(id);
4571
+ res.json({ ok: true, restored });
4572
+ }
4573
+ catch (err) {
4574
+ res.status(500).json({ error: String(err) });
4575
+ }
4576
+ });
4577
+ app.post('/api/memory/chunks/:id/pin', async (req, res) => {
4578
+ try {
4579
+ const id = Number(req.params.id);
4580
+ if (!Number.isFinite(id) || id <= 0) {
4581
+ res.status(400).json({ error: 'invalid chunk id' });
4582
+ return;
4583
+ }
4584
+ const pinned = !!(req.body?.pinned ?? true);
4585
+ const gateway = await getGateway();
4586
+ const store = gateway.assistant?.memoryStore;
4587
+ if (!store?.setPinned) {
4588
+ res.status(503).json({ error: 'Memory store not available' });
4589
+ return;
4590
+ }
4591
+ const ok = store.setPinned(id, pinned);
4592
+ res.json({ ok: true, updated: ok, pinned });
4593
+ }
4594
+ catch (err) {
4595
+ res.status(500).json({ error: String(err) });
4596
+ }
4597
+ });
4598
+ app.get('/api/memory/chunks/:id/history', async (req, res) => {
4599
+ try {
4600
+ const id = Number(req.params.id);
4601
+ if (!Number.isFinite(id) || id <= 0) {
4602
+ res.status(400).json({ error: 'invalid chunk id' });
4603
+ return;
4604
+ }
4605
+ const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100);
4606
+ const gateway = await getGateway();
4607
+ const store = gateway.assistant?.memoryStore;
4608
+ if (!store?.getChunkHistory) {
4609
+ res.status(503).json({ error: 'Memory store not available' });
4610
+ return;
4611
+ }
4612
+ const history = store.getChunkHistory(id, limit);
4613
+ res.json({ ok: true, history });
4327
4614
  }
4328
4615
  catch (err) {
4329
4616
  res.status(500).json({ error: String(err) });
@@ -10474,6 +10761,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
10474
10761
  <div class="tab-bar" id="intelligence-tabs">
10475
10762
  <button class="active" onclick="switchTab('intelligence','search')">Search</button>
10476
10763
  <button onclick="switchTab('intelligence','graph')">Knowledge Graph</button>
10764
+ <button onclick="switchTab('intelligence','user-model')">User Model</button>
10477
10765
  <button onclick="switchTab('intelligence','memory')">Memory Stats</button>
10478
10766
  <button onclick="switchTab('intelligence','seed')">Seed Upload</button>
10479
10767
  <button onclick="switchTab('intelligence','sources')">Sources</button>
@@ -10508,6 +10796,21 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
10508
10796
  </div>
10509
10797
  </div>
10510
10798
 
10799
+ <!-- User Model — MemGPT-style core memory blocks always loaded into context -->
10800
+ <div class="tab-pane" id="tab-intelligence-user-model">
10801
+ <div style="color:var(--muted,#888);margin-bottom:12px;font-size:13px">
10802
+ What the agent always knows about you. These slots load into every conversation's context (above retrieved memory). Edit directly to correct or steer.
10803
+ </div>
10804
+ <div style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
10805
+ <label style="font-size:13px;color:var(--text-secondary)">Scope:</label>
10806
+ <select id="user-model-scope" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text);font-size:13px" onchange="loadUserModel()">
10807
+ <option value="">Global</option>
10808
+ </select>
10809
+ <button class="btn" onclick="loadUserModel()" style="font-size:13px">Refresh</button>
10810
+ </div>
10811
+ <div id="user-model-panel"><div class="empty-state">Loading user model…</div></div>
10812
+ </div>
10813
+
10511
10814
  <!-- Seed Upload -->
10512
10815
  <div class="tab-pane" id="tab-intelligence-seed">
10513
10816
  <div class="card" style="padding:20px;margin-bottom:16px">
@@ -16105,6 +16408,17 @@ async function sendChat() {
16105
16408
  asstMeta.className = 'chat-meta';
16106
16409
  asstMeta.textContent = new Date().toLocaleTimeString();
16107
16410
  asstBubble.appendChild(asstMeta);
16411
+ // Recall trace affordance — shows which memory chunks powered this answer.
16412
+ if (d.trace && d.trace.chunkCount > 0) {
16413
+ var traceLink = document.createElement('div');
16414
+ traceLink.className = 'chat-trace-link';
16415
+ traceLink.style.cssText = 'margin-top:6px;font-size:11px;color:var(--text-muted);cursor:pointer;user-select:none';
16416
+ traceLink.textContent = '🧠 ' + d.trace.chunkCount + ' source' + (d.trace.chunkCount === 1 ? '' : 's');
16417
+ traceLink.dataset.traceId = String(d.trace.id);
16418
+ traceLink.dataset.expanded = 'false';
16419
+ traceLink.onclick = function() { toggleRecallTrace(traceLink); };
16420
+ asstBubble.appendChild(traceLink);
16421
+ }
16108
16422
  asstRow.appendChild(asstBubble);
16109
16423
  container.appendChild(asstRow);
16110
16424
  } catch(e) {
@@ -16123,6 +16437,277 @@ async function sendChat() {
16123
16437
  container.scrollTop = container.scrollHeight;
16124
16438
  }
16125
16439
 
16440
+ // ── Recall trace expansion ────────────────
16441
+ async function toggleRecallTrace(linkEl) {
16442
+ var traceId = linkEl.dataset.traceId;
16443
+ var expanded = linkEl.dataset.expanded === 'true';
16444
+ // Find or create the panel directly after the link
16445
+ var panel = linkEl.nextElementSibling && linkEl.nextElementSibling.classList && linkEl.nextElementSibling.classList.contains('chat-trace-panel')
16446
+ ? linkEl.nextElementSibling : null;
16447
+
16448
+ if (expanded) {
16449
+ if (panel) panel.remove();
16450
+ linkEl.dataset.expanded = 'false';
16451
+ linkEl.textContent = linkEl.textContent.replace('▾', '').trim();
16452
+ return;
16453
+ }
16454
+
16455
+ if (!panel) {
16456
+ panel = document.createElement('div');
16457
+ panel.className = 'chat-trace-panel';
16458
+ panel.style.cssText = 'margin-top:6px;padding:8px 10px;border-left:2px solid var(--border);background:var(--bg-soft);border-radius:4px;font-size:11px';
16459
+ panel.innerHTML = '<div style="color:var(--text-muted)">Loading sources…</div>';
16460
+ linkEl.insertAdjacentElement('afterend', panel);
16461
+ }
16462
+
16463
+ try {
16464
+ var r = await apiFetch('/api/recall-traces/' + encodeURIComponent(traceId));
16465
+ var d = await r.json();
16466
+ if (!d || !d.trace) {
16467
+ panel.innerHTML = '<div style="color:var(--red)">Trace not found</div>';
16468
+ return;
16469
+ }
16470
+ var t = d.trace;
16471
+ var html = '<div style="color:var(--text-muted);margin-bottom:6px"><strong>Query:</strong> ' + esc(t.query) + '</div>';
16472
+ html += '<div style="display:flex;flex-direction:column;gap:6px">';
16473
+ for (var i = 0; i < t.chunks.length; i++) {
16474
+ var c = t.chunks[i];
16475
+ var snippet = (c.content || '').slice(0, 240);
16476
+ if ((c.content || '').length > 240) snippet += '…';
16477
+ var badges = '';
16478
+ if (c.pinned) badges += '<span style="background:var(--accent);color:#fff;padding:1px 5px;border-radius:3px;font-size:10px;margin-left:4px">📌 pinned</span>';
16479
+ if (c.consolidated) badges += '<span style="background:var(--bg);color:var(--text-muted);padding:1px 5px;border-radius:3px;font-size:10px;margin-left:4px">consolidated</span>';
16480
+ if (c.derivedFrom && c.derivedFrom.length) badges += '<span style="background:var(--bg);color:var(--text-muted);padding:1px 5px;border-radius:3px;font-size:10px;margin-left:4px" title="Derived from chunk ids: ' + c.derivedFrom.join(', ') + '">↳ ' + c.derivedFrom.length + ' source' + (c.derivedFrom.length === 1 ? '' : 's') + '</span>';
16481
+ html += '<div style="border-left:2px solid var(--border);padding-left:8px">';
16482
+ html += '<div style="display:flex;justify-content:space-between;align-items:center"><span style="color:var(--accent);font-weight:600">' + esc(c.sourceFile || '?') + '</span><span style="color:var(--text-muted)">score ' + (typeof c.score === 'number' ? c.score.toFixed(3) : '?') + badges + '</span></div>';
16483
+ html += '<div style="color:var(--text-muted);font-size:10px;margin:2px 0">' + esc(c.section || '') + '</div>';
16484
+ html += '<div style="white-space:pre-wrap">' + esc(snippet) + '</div>';
16485
+ html += '</div>';
16486
+ }
16487
+ html += '</div>';
16488
+ panel.innerHTML = html;
16489
+ linkEl.dataset.expanded = 'true';
16490
+ } catch (e) {
16491
+ panel.innerHTML = '<div style="color:var(--red)">Failed to load: ' + esc(String(e)) + '</div>';
16492
+ }
16493
+ }
16494
+
16495
+ // ── User Model (MemGPT-style core memory) ────────
16496
+ async function loadUserModel() {
16497
+ var panel = document.getElementById('user-model-panel');
16498
+ if (!panel) return;
16499
+ var scope = document.getElementById('user-model-scope');
16500
+ var agentSlug = scope && scope.value ? scope.value : '';
16501
+ panel.innerHTML = '<div class="empty-state">Loading…</div>';
16502
+ try {
16503
+ var qs = agentSlug ? '?agentSlug=' + encodeURIComponent(agentSlug) : '';
16504
+ var r = await apiFetch('/api/user-model' + qs);
16505
+ var d = await r.json();
16506
+ if (!d.ok) {
16507
+ panel.innerHTML = '<div class="empty-state">Failed to load: ' + esc(d.error || 'unknown error') + '</div>';
16508
+ return;
16509
+ }
16510
+ var labelMap = {
16511
+ user_facts: 'User Facts',
16512
+ goals: 'Active Goals',
16513
+ relationships: 'Key Relationships',
16514
+ agent_persona: 'Agent Persona',
16515
+ };
16516
+ var helpMap = {
16517
+ user_facts: 'Who they are: name, role, location, lasting preferences (writing style, tools, communication style).',
16518
+ goals: 'What they\\'re actively working toward right now. Updated as goals shift.',
16519
+ relationships: 'People, projects, channels they regularly interact with.',
16520
+ agent_persona: 'For multi-agent: this agent\\'s self-identity in its working relationship with the user.',
16521
+ };
16522
+ var html = '<div style="display:flex;flex-direction:column;gap:14px">';
16523
+ for (var i = 0; i < d.blocks.length; i++) {
16524
+ var b = d.blocks[i];
16525
+ var label = labelMap[b.slot] || b.slot;
16526
+ var help = helpMap[b.slot] || '';
16527
+ var charsUsed = (b.content || '').length;
16528
+ var pct = Math.round((charsUsed / (b.charLimit || 2000)) * 100);
16529
+ var pctColor = pct > 90 ? 'var(--red,#ef4444)' : pct > 70 ? 'var(--accent,#f59e0b)' : 'var(--text-muted)';
16530
+ var scopeLabel = b.agentSlug ? 'agent=' + esc(b.agentSlug) : 'global';
16531
+ var updated = b.updatedAt ? 'updated ' + esc(b.updatedAt) : 'never set';
16532
+ html += '<div class="card" style="padding:14px">';
16533
+ html += '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">';
16534
+ html += '<div><div style="font-weight:600;font-size:14px">' + esc(label) + '</div>';
16535
+ html += '<div style="font-size:11px;color:var(--text-muted);margin-top:2px">' + esc(help) + '</div></div>';
16536
+ html += '<div style="font-size:11px;color:' + pctColor + '">' + charsUsed + '/' + (b.charLimit || 2000) + ' chars · ' + scopeLabel + ' · ' + updated + '</div>';
16537
+ html += '</div>';
16538
+ html += '<textarea id="um-textarea-' + esc(b.slot) + '" style="width:100%;min-height:80px;padding:8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text);font-family:inherit;font-size:13px;resize:vertical">' + esc(b.content || '') + '</textarea>';
16539
+ html += '<div style="display:flex;gap:6px;margin-top:8px;justify-content:flex-end">';
16540
+ html += '<button class="btn" onclick="clearUserModelSlot(\\'' + esc(b.slot) + '\\')" style="font-size:12px">Clear</button>';
16541
+ html += '<button class="btn-primary" onclick="saveUserModelSlot(\\'' + esc(b.slot) + '\\')" style="font-size:12px">Save</button>';
16542
+ html += '</div>';
16543
+ html += '</div>';
16544
+ }
16545
+ html += '</div>';
16546
+ panel.innerHTML = html;
16547
+ } catch (e) {
16548
+ panel.innerHTML = '<div class="empty-state">Failed to load: ' + esc(String(e)) + '</div>';
16549
+ }
16550
+ }
16551
+
16552
+ async function saveUserModelSlot(slot) {
16553
+ var ta = document.getElementById('um-textarea-' + slot);
16554
+ if (!ta) return;
16555
+ var scope = document.getElementById('user-model-scope');
16556
+ var agentSlug = scope && scope.value ? scope.value : null;
16557
+ try {
16558
+ var r = await apiFetch('/api/user-model/' + encodeURIComponent(slot), {
16559
+ method: 'PUT',
16560
+ headers: { 'Content-Type': 'application/json' },
16561
+ body: JSON.stringify({ content: ta.value, agentSlug: agentSlug }),
16562
+ });
16563
+ var d = await r.json();
16564
+ if (d.ok) {
16565
+ toast('Saved ' + slot + (d.truncated ? ' (truncated to char_limit)' : ''), 'success');
16566
+ loadUserModel();
16567
+ } else {
16568
+ toast('Save failed: ' + (d.error || 'unknown'), 'error');
16569
+ }
16570
+ } catch (e) { toast('Save failed: ' + String(e), 'error'); }
16571
+ }
16572
+
16573
+ async function clearUserModelSlot(slot) {
16574
+ if (!confirm('Clear the "' + slot + '" slot? This deletes everything currently stored there.')) return;
16575
+ var scope = document.getElementById('user-model-scope');
16576
+ var agentSlug = scope && scope.value ? scope.value : null;
16577
+ try {
16578
+ var qs = agentSlug ? '?agentSlug=' + encodeURIComponent(agentSlug) : '';
16579
+ var r = await apiFetch('/api/user-model/' + encodeURIComponent(slot) + qs, { method: 'DELETE' });
16580
+ var d = await r.json();
16581
+ if (d.ok) {
16582
+ toast('Cleared ' + slot, 'success');
16583
+ loadUserModel();
16584
+ } else {
16585
+ toast('Clear failed: ' + (d.error || 'unknown'), 'error');
16586
+ }
16587
+ } catch (e) { toast('Clear failed: ' + String(e), 'error'); }
16588
+ }
16589
+
16590
+ // Auto-load when the User Model tab becomes active
16591
+ (function() {
16592
+ document.addEventListener('DOMContentLoaded', function() {
16593
+ var obs = new MutationObserver(function() {
16594
+ var pane = document.getElementById('tab-intelligence-user-model');
16595
+ if (pane && pane.classList.contains('active')) {
16596
+ // Populate the agent scope dropdown from the team list (best-effort)
16597
+ var scope = document.getElementById('user-model-scope');
16598
+ if (scope && scope.children.length === 1) {
16599
+ fetch('/api/team/agents').then(function(r) { return r.json(); }).then(function(d) {
16600
+ var agents = (d && d.agents) || [];
16601
+ for (var i = 0; i < agents.length; i++) {
16602
+ var opt = document.createElement('option');
16603
+ opt.value = agents[i].slug;
16604
+ opt.textContent = 'Agent: ' + (agents[i].name || agents[i].slug);
16605
+ scope.appendChild(opt);
16606
+ }
16607
+ }).catch(function() { /* team API optional */ });
16608
+ }
16609
+ loadUserModel();
16610
+ }
16611
+ });
16612
+ var content = document.querySelector('.content');
16613
+ if (content) obs.observe(content, { subtree: true, attributes: true, attributeFilter: ['class'] });
16614
+ });
16615
+ })();
16616
+
16617
+ // ── Memory chunk CRUD (search panel actions) ────
16618
+ async function editChunk(id) {
16619
+ var row = document.getElementById('chunk-row-' + id);
16620
+ if (!row) return;
16621
+ var contentDiv = document.getElementById('chunk-content-' + id);
16622
+ if (!contentDiv) return;
16623
+ // If already editing, no-op
16624
+ if (row.dataset.editing === 'true') return;
16625
+ row.dataset.editing = 'true';
16626
+
16627
+ // Fetch full content (search result is truncated to 500 chars)
16628
+ try {
16629
+ var r = await apiFetch('/api/memory/chunks/' + id);
16630
+ var d = await r.json();
16631
+ if (!d.ok || !d.chunk) {
16632
+ toast('Failed to load chunk: ' + (d.error || 'unknown'), 'error');
16633
+ row.dataset.editing = 'false';
16634
+ return;
16635
+ }
16636
+ var fullContent = d.chunk.content || '';
16637
+ var section = d.chunk.section || '';
16638
+ contentDiv.innerHTML =
16639
+ '<div style="margin-top:6px">'
16640
+ + '<input type="text" id="edit-section-' + id + '" placeholder="Section" value="' + esc(section) + '" style="width:100%;padding:6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text);font-size:12px;margin-bottom:6px">'
16641
+ + '<textarea id="edit-content-' + id + '" style="width:100%;min-height:140px;padding:8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text);font-family:inherit;font-size:13px;resize:vertical">' + esc(fullContent) + '</textarea>'
16642
+ + '<div style="display:flex;gap:6px;margin-top:6px;justify-content:flex-end">'
16643
+ + '<button class="btn" style="font-size:12px" onclick="cancelEditChunk(' + id + ')">Cancel</button>'
16644
+ + '<button class="btn-primary" style="font-size:12px" onclick="saveEditChunk(' + id + ')">Save</button>'
16645
+ + '</div></div>';
16646
+ } catch (e) {
16647
+ toast('Failed to load chunk: ' + String(e), 'error');
16648
+ row.dataset.editing = 'false';
16649
+ }
16650
+ }
16651
+
16652
+ async function saveEditChunk(id) {
16653
+ var contentEl = document.getElementById('edit-content-' + id);
16654
+ var sectionEl = document.getElementById('edit-section-' + id);
16655
+ if (!contentEl) return;
16656
+ try {
16657
+ var r = await apiFetch('/api/memory/chunks/' + id, {
16658
+ method: 'PUT',
16659
+ headers: { 'Content-Type': 'application/json' },
16660
+ body: JSON.stringify({
16661
+ content: contentEl.value,
16662
+ section: sectionEl ? sectionEl.value : undefined,
16663
+ }),
16664
+ });
16665
+ var d = await r.json();
16666
+ if (d.ok) {
16667
+ toast('Chunk saved', 'success');
16668
+ runMemorySearch(); // re-render with updated data
16669
+ } else {
16670
+ toast('Save failed: ' + (d.error || 'unknown'), 'error');
16671
+ }
16672
+ } catch (e) { toast('Save failed: ' + String(e), 'error'); }
16673
+ }
16674
+
16675
+ function cancelEditChunk(id) {
16676
+ // Easiest: re-run the search to restore original rendering
16677
+ runMemorySearch();
16678
+ }
16679
+
16680
+ async function togglePinChunk(id, pinned) {
16681
+ try {
16682
+ var r = await apiFetch('/api/memory/chunks/' + id + '/pin', {
16683
+ method: 'POST',
16684
+ headers: { 'Content-Type': 'application/json' },
16685
+ body: JSON.stringify({ pinned: pinned }),
16686
+ });
16687
+ var d = await r.json();
16688
+ if (d.ok) {
16689
+ toast(pinned ? 'Pinned' : 'Unpinned', 'success');
16690
+ runMemorySearch();
16691
+ } else {
16692
+ toast('Pin failed: ' + (d.error || 'unknown'), 'error');
16693
+ }
16694
+ } catch (e) { toast('Pin failed: ' + String(e), 'error'); }
16695
+ }
16696
+
16697
+ async function deleteChunk(id) {
16698
+ if (!confirm('Delete this chunk? It will be excluded from search and retrieval. (Soft-delete — recoverable via the database.)')) return;
16699
+ try {
16700
+ var r = await apiFetch('/api/memory/chunks/' + id, { method: 'DELETE' });
16701
+ var d = await r.json();
16702
+ if (d.ok) {
16703
+ toast(d.removed ? 'Chunk deleted' : 'Chunk was already deleted', 'success');
16704
+ runMemorySearch();
16705
+ } else {
16706
+ toast('Delete failed: ' + (d.error || 'unknown'), 'error');
16707
+ }
16708
+ } catch (e) { toast('Delete failed: ' + String(e), 'error'); }
16709
+ }
16710
+
16126
16711
  // ── Profile Switching ─────────────────────
16127
16712
  async function loadProfiles() {
16128
16713
  try {
@@ -16748,13 +17333,22 @@ async function runMemorySearch() {
16748
17333
 
16749
17334
  for (const r of d.results) {
16750
17335
  const score = Math.abs(r.score || 0).toFixed(2);
16751
- html += '<div class="search-result">'
16752
- + '<div class="search-result-header">'
16753
- + '<span class="search-result-file">' + esc(r.source_file) + '</span>'
16754
- + '<span class="search-result-score">score: ' + score + '</span>'
17336
+ const pinned = r.pinned ? ' 📌' : '';
17337
+ const idAttr = r.id ? String(r.id) : '';
17338
+ html += '<div class="search-result" data-chunk-id="' + esc(idAttr) + '" id="chunk-row-' + esc(idAttr) + '">'
17339
+ + '<div class="search-result-header" style="display:flex;justify-content:space-between;align-items:center">'
17340
+ + '<span class="search-result-file">' + esc(r.source_file) + pinned + '</span>'
17341
+ + '<div style="display:flex;gap:6px;align-items:center">'
17342
+ + '<span class="search-result-score" style="font-size:11px;color:var(--text-muted)">score ' + score + '</span>'
17343
+ + (idAttr ? (
17344
+ '<button class="btn" style="font-size:11px;padding:2px 8px" onclick="editChunk(' + idAttr + ')">Edit</button>'
17345
+ + '<button class="btn" style="font-size:11px;padding:2px 8px" onclick="togglePinChunk(' + idAttr + ',' + (r.pinned ? 'false' : 'true') + ')">' + (r.pinned ? 'Unpin' : 'Pin') + '</button>'
17346
+ + '<button class="btn" style="font-size:11px;padding:2px 8px;color:var(--red,#ef4444)" onclick="deleteChunk(' + idAttr + ')">Delete</button>'
17347
+ ) : '')
17348
+ + '</div>'
16755
17349
  + '</div>'
16756
17350
  + '<div class="search-result-section">' + esc(r.section || '') + ' &middot; ' + esc(r.chunk_type || '') + '</div>'
16757
- + '<div class="search-result-content">' + esc((r.content || '').slice(0, 500)) + '</div>'
17351
+ + '<div class="search-result-content" id="chunk-content-' + esc(idAttr) + '">' + esc((r.content || '').slice(0, 500)) + '</div>'
16758
17352
  + '</div>';
16759
17353
  }
16760
17354
  container.innerHTML = html;