@gotcos/glasses-server 6.98.0 → 6.99.12

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +147 -9
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/display-bus.ts +3 -0
  8. package/server/lib/drop-destinations.ts +134 -0
  9. package/server/lib/drop-history-search.ts +97 -0
  10. package/server/lib/drop-p2-boot.ts +60 -0
  11. package/server/lib/drop-p2-runtime.ts +237 -0
  12. package/server/lib/drop-p2-settings.ts +174 -0
  13. package/server/lib/drop-resolution.ts +168 -0
  14. package/server/lib/drop-router.ts +325 -0
  15. package/server/lib/drop-service.ts +739 -0
  16. package/server/lib/drop-settings.ts +45 -0
  17. package/server/lib/drop-store.ts +428 -0
  18. package/server/lib/drop-when.ts +265 -0
  19. package/server/lib/glasses-pairing.ts +15 -0
  20. package/server/lib/learning-needs-you.ts +111 -0
  21. package/server/lib/live-cues-v2-data.ts +336 -26
  22. package/server/lib/live-cues-v2-settings.ts +2 -1
  23. package/server/lib/live-cues-v2.ts +9 -5
  24. package/server/lib/meeting-consolidation-run.ts +169 -0
  25. package/server/lib/meeting-store.ts +61 -0
  26. package/server/lib/meeting-title-steer.ts +200 -0
  27. package/server/lib/network-policy.ts +84 -6
  28. package/server/lib/permission-broker.ts +11 -0
  29. package/server/lib/ping-budget.ts +149 -0
  30. package/server/lib/ping-engine.ts +245 -0
  31. package/server/lib/ping-feed-sink.ts +33 -0
  32. package/server/lib/ping-sources.ts +192 -0
  33. package/server/lib/query-job-coordinator.ts +5 -2
  34. package/server/lib/query-job-runtime.ts +45 -4
  35. package/server/lib/query-job-types.ts +10 -1
  36. package/server/lib/reminder-scheduler.ts +153 -0
  37. package/server/lib/tailnet-self-name.ts +103 -0
  38. package/server/lib/tailscale-nudge.ts +40 -2
  39. package/server/lib/task-store.ts +32 -0
  40. package/server/lib/web-ask-admission.ts +56 -0
  41. package/server/lib/web-live-chunks.ts +273 -0
  42. package/server/lib/web-memories-bridge.ts +323 -0
  43. package/server/lib/web-session-auth.ts +492 -0
  44. package/server/lib/web-usage-bridge.ts +154 -0
  45. package/server/lib/work-search.ts +14 -1
  46. package/server/lib/your-move-count.ts +58 -0
  47. package/server/routes/display.ts +11 -1
  48. package/server/routes/drops.ts +260 -0
  49. package/server/routes/health.ts +16 -0
  50. package/server/routes/live-cues.ts +5 -4
  51. package/server/routes/meeting.ts +61 -5
  52. package/server/routes/permission-broker.ts +23 -3
  53. package/server/routes/transcribe-stream.ts +34 -3
  54. package/server/routes/web-control.ts +569 -0
  55. package/server/routes/welcome-context.ts +2 -1
  56. package/server/web-control/app.css +519 -0
  57. package/server/web-control/app.js +3643 -0
  58. package/server/web-control/control.css +312 -0
  59. package/server/web-control/fonts/DMSans.ttf +0 -0
  60. package/server/web-control/fonts/Fraunces.ttf +0 -0
  61. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  62. package/server/web-control/index.html +275 -0
  63. package/server/web-control/mark.svg +1 -0
  64. package/server/web-control/memories/d3.min.js +2 -0
  65. package/server/web-control/memories/graph-explorer.css +200 -0
  66. package/server/web-control/memories/graph-explorer.js +748 -0
  67. package/server/web-control/memories/memories-app.js +1561 -0
  68. package/server/web-control/memories/memories-theme.css +73 -0
  69. package/server/web-control/memories/memories.html +72 -0
  70. package/server/web-control/memories/memory-stewardship.js +63 -0
  71. package/server/web-control/memories/memory-workspace.js +216 -0
  72. package/server/web-control/panes/cos-chat.css +157 -0
  73. package/server/web-control/panes/cos-chat.js +973 -0
  74. package/server/web-control/panes/dictation.js +173 -0
  75. package/server/web-control/panes/meetings.css +257 -0
  76. package/server/web-control/panes/meetings.js +2264 -0
  77. package/server/web-control/panes/messages.js +711 -0
  78. package/server/web-control/panes/questions.css +32 -0
  79. package/server/web-control/panes/questions.js +275 -0
  80. package/server/web-control/panes/sessions.css +452 -0
  81. package/server/web-control/panes/settings.js +292 -0
  82. package/server/web-control/panes/speakers.css +269 -0
  83. package/server/web-control/panes/speakers.js +2755 -0
  84. package/server/web-control/panes/threads.js +200 -0
  85. package/server/web-control/panes/work.css +261 -0
  86. package/server/web-control/panes/work.js +1667 -0
  87. package/server/web-control/shell.js +294 -0
  88. package/server/web-control/theme.js +25 -0
  89. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,1561 @@
1
+ /* COS Control · Memories (0.5.191).
2
+ The reviewed prototype (operations/personal/wk36_2026/design/cos-control-learning/index.html),
3
+ with every piece of demo state replaced by live data through the app's helper. This page never
4
+ holds the server token: it posts {id, op, args} to the native host, which runs the helper and
5
+ resolves the promise with {ok, message, details}. Actions without a backend tonight are shown
6
+ disabled with the reason, never faked. */
7
+ (function () {
8
+ 'use strict';
9
+
10
+ // ── bridge ──────────────────────────────────────────────────────
11
+ var pending = {}, nextId = 1;
12
+ function call(op, args) {
13
+ return new Promise(function (resolve, reject) {
14
+ var id = nextId++;
15
+ pending[id] = { resolve: resolve, reject: reject };
16
+ var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.cos;
17
+ if (handler) {
18
+ try { handler.postMessage({ id: id, op: op, args: args || {} }); }
19
+ catch (e) { delete pending[id]; reject(e); }
20
+ return;
21
+ }
22
+ delete pending[id];
23
+ var a = args || {};
24
+ if (op === 'copy') {
25
+ // A plain-http tailnet page has no navigator.clipboard. Fall back to a selected
26
+ // textarea, and reject when both fail so the page never says "Copied" for nothing.
27
+ var text = String(a.text || '');
28
+ var viaTextarea = function () {
29
+ var ta = document.createElement('textarea');
30
+ ta.value = text; ta.setAttribute('readonly', '');
31
+ ta.style.position = 'fixed'; ta.style.top = '-1000px'; ta.style.opacity = '0';
32
+ document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, text.length);
33
+ var ok = false;
34
+ try { ok = document.execCommand('copy'); } catch (_e) { ok = false; }
35
+ document.body.removeChild(ta);
36
+ if (ok) resolve({}); else reject(new Error('This browser would not copy. Select the text and copy it yourself.'));
37
+ };
38
+ if (navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext) navigator.clipboard.writeText(text).then(function () { resolve({}); }, viaTextarea);
39
+ else viaTextarea();
40
+ return;
41
+ }
42
+ if (op === 'pick.folder') {
43
+ var chosen = window.prompt('Enter folder path on your Mac for Knowledge to index:', '');
44
+ if (chosen && chosen.trim()) resolve({ path: chosen.trim() });
45
+ else reject(new Error('No folder chosen.'));
46
+ return;
47
+ }
48
+ if (op === 'open.section' || op === 'cues.open') {
49
+ try { window.parent.postMessage({ type: 'cos-control-nav', op: op, args: a }, window.location.origin); } catch (_e) {}
50
+ resolve({});
51
+ return;
52
+ }
53
+ if (op === 'memory.reveal') {
54
+ reject(new Error('Revealing files in Finder is available in the Mac app.'));
55
+ return;
56
+ }
57
+ // The browser session cookie is the only credential; this page never holds the token.
58
+ var headers = { 'Content-Type': 'application/json', 'X-COS-Web': '1' };
59
+ fetch('/api/web-bridge/memories', {
60
+ method: 'POST',
61
+ headers: headers,
62
+ credentials: 'same-origin',
63
+ body: JSON.stringify({ id: id, op: op, args: a })
64
+ }).then(function (r) { return r.json(); }).then(function (payload) {
65
+ if (payload && payload.ok) resolve(payload.details || {});
66
+ else reject(new Error((payload && payload.message) || 'The server bridge did not answer.'));
67
+ }).catch(function (err) {
68
+ reject(err instanceof Error ? err : new Error('Not connected to COS Control server.'));
69
+ });
70
+ });
71
+ }
72
+ window.cosBridge = {
73
+ resolve: function (id, payload) {
74
+ var p = pending[id]; if (!p) return; delete pending[id];
75
+ if (payload && payload.ok) p.resolve(payload.details || {});
76
+ else p.reject(new Error((payload && payload.message) || 'The helper did not answer.'));
77
+ },
78
+ refresh: function () { loadAll(); },
79
+ show: function (view) { if (view) { state.view = view === 'knowledge' ? 'knowledge' : 'learning'; if (view !== 'knowledge') state.filter = view; render(); } }
80
+ };
81
+
82
+ var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]; }); };
83
+ var fmt = function (n) { return Number(n || 0).toLocaleString(); };
84
+ function stamp(iso) { if (!iso) return ''; var d=new Date(iso); return isNaN(d.getTime()) ? String(iso) : d.toLocaleString(undefined,{dateStyle:'medium',timeStyle:'short'})+' '+Intl.DateTimeFormat().resolvedOptions().timeZone; }
85
+ function dateOnly(iso) { return iso ? String(iso).slice(0, 10) : ''; }
86
+ function toast(text) { var el = document.querySelector('#toast'); el.textContent = text; el.classList.remove('hidden'); clearTimeout(window.toastTimer); window.toastTimer = setTimeout(function () { el.classList.add('hidden'); }, 3400); }
87
+
88
+ // ── state ───────────────────────────────────────────────────────
89
+ var state = {
90
+ view: 'learning', filter: 'recent', selected: null, knowledgeTab: 'graph', knowledgeFocus: null,
91
+ status: null, learningStatus: null, graphStatus: null,
92
+ recent: [], recentTotal: null, recentCursor: null, review: [], reviewCount: null, memories: [], memoriesTotal: null,
93
+ memoryQuery: '', memoryHits: null, memoryCursor:null, memorySearchCursor:null, memoryPageCapable:false, coverage: {},
94
+ cues: null,
95
+ applied: [], appliedTotal: null, appliedCursor: null, appliedDays: 7, appliedCoverage: {}, appliedLoaded: false, appliedLoading: false,
96
+ detail: {}, memoryDetail: {}, passages: {}, loading: {}, errors: {},
97
+ graphFocus: null, graphFocusChosen: false, ingesting: null, ingestLimit: 10, ingestWatch: null, graphQuery: '',
98
+ progress: null, fetchWatch: null, modalOpen: null, graphAsk: { q: '', busy: false, answer: null, error: null, cards: [], cardsBusy: false }, graphNeighbors: [], guardrails: null, guardrailsRun: null, guardrailsBusy: null, guardrailsLoading: false, guardrailsError: null, guardrailsRubricLines: 0, memoryReviewBusy: null, setup: null, setupLoading: false, setupError: null, setupBusy: null, armed: null, askQ: '', askAnswer: null, askBusy: false, knowledgeTabChosen: false, graphController: null, copyId: null, copyOptions: { sources: true, graph: false }
99
+ };
100
+
101
+ // ── loading ─────────────────────────────────────────────────────
102
+ function loadAll() {
103
+ state.errors = {};
104
+ call('status').then(function (d) { state.status = d; chooseDefaultFocus(); render(); }, function (e) { state.errors.status = e.message; render(); });
105
+ loadLearning(false);
106
+ loadReview(false);
107
+ loadMemoryPage(false);
108
+ loadApplied(false);
109
+ loadCues();
110
+
111
+ loadGraphStatus();
112
+ }
113
+ // 0.5.327: Cues to rate and Rated cues. The app answers from its own cue store; the two entries show only when it says
114
+ // the server keeps a cue history (`available`), and either opens the app's native cue review, never a page view.
115
+ function loadCues() {
116
+ call('cues.summary').then(function (d) { state.cues = d && d.available === true ? d : null; render(); },
117
+ function () { state.cues = null; render(); });
118
+ }
119
+ var memoryRequestSerial=0,learningRequestSerial=0,policyRequestSerial=0;
120
+ function loadMemoryPage(more) {
121
+ if(more&&state.memoryPageLoading)return Promise.resolve();
122
+ var serial=++memoryRequestSerial;state.memoryPageLoading=true;
123
+ var q=state.memoryQuery.trim(),cursor=more?(q?state.memorySearchCursor:state.memoryCursor):null;
124
+ return call('workspace.request',{action:'memory_page',q:q,limit:25,cursor:cursor}).then(function(d){
125
+ if(serial!==memoryRequestSerial||state.memoryQuery.trim()!==q)return;
126
+ if(d.protocol!==1||!Array.isArray(d.items))throw new Error('Paged memory is unavailable in this version.');
127
+ state.memoryPageCapable=true;
128
+ if(q){state.memoryHits=more?(state.memoryHits||[]).concat(d.items):d.items;state.memorySearchCursor=d.next_cursor;}
129
+ else{state.memoryHits=null;state.memories=more?state.memories.concat(d.items):d.items;state.memoriesTotal=d.matched_total;state.memoryCursor=d.next_cursor;}
130
+ render();
131
+ }).catch(function(error){
132
+ if(serial!==memoryRequestSerial||state.memoryQuery.trim()!==q)return;
133
+ if(more){toast(error.message+' Restart this lookup to refresh its snapshot.');return;}
134
+ state.memoryPageCapable=false;
135
+ return call(q?'memories.search':'memories.list',q?{q:q,limit:20}:{limit:50}).then(function(d){
136
+ if(serial!==memoryRequestSerial||state.memoryQuery.trim()!==q)return;
137
+ if(q)state.memoryHits=d.hits||[];else{state.memoryHits=null;state.memories=d.memories||[];state.memoriesTotal=d.total;}
138
+ render();
139
+ },function(e){if(serial!==memoryRequestSerial)return;state.errors.memories=e.message;render();});
140
+ }).finally(function(){if(serial===memoryRequestSerial)state.memoryPageLoading=false;});
141
+ }
142
+ // The graph opens on the person this COS is about (the profile's owner_name,
143
+ // resolved through a search so the entity's exact spelling wins), not on "COS".
144
+ // A focus the user picks or recenters on is never overridden.
145
+ function chooseDefaultFocus() {
146
+ var name = state.status && state.status.ownerName;
147
+ if (!name || state.graphFocusChosen || state.graphFocusResolved) return;
148
+ state.graphFocusResolved = true;
149
+ call('graph.search', { q: name, limit: 5 }).then(function (d) {
150
+ var items = d.items || [];
151
+ var exact = items.find(function (it) { return String(it.id).toLowerCase() === String(name).toLowerCase(); });
152
+ var pick = exact || items.find(function (it) { return String(it.type || '').toLowerCase() === 'person'; }) || items[0];
153
+ if (pick && !state.graphFocusChosen && state.status && state.status.ownerName === name) { state.graphFocus = pick.id; if (state.view === 'knowledge' && state.knowledgeTab === 'graph') render(); }
154
+ }, function () {});
155
+ }
156
+ // While the ingest lock is held, read the run's progress every 5 s (and the
157
+ // Sync card every 15 s), four hours at most; when the lock frees, say what
158
+ // the run indexed and what is still queued. A run started elsewhere holds
159
+ // the lock but writes no log here, so it shows as external with the counts.
160
+ function watchIngest() {
161
+ if (state.ingestWatch) return;
162
+ var before = state.graphStatus && state.graphStatus.queue ? Number(state.graphStatus.queue.pending) : null, ticks = 0;
163
+ function finish() {
164
+ clearInterval(state.ingestWatch); state.ingestWatch = null;
165
+ var pr = state.progress || {};
166
+ loadGraphStatus().then(function () {
167
+ var g = state.graphStatus || {}, after = g.queue ? Number(g.queue.pending) : null;
168
+ var done = pr.done ? fmt(pr.done) + ' indexed' + (pr.failed ? ', ' + fmt(pr.failed) + ' failed' : '') : (before != null && after != null && before > after ? fmt(before - after) + ' indexed' : 'Indexing finished');
169
+ state.ingesting = { note: done + (after != null ? ' · ' + fmt(after) + ' still queued' : '') };
170
+ if (state.knowledgeTab === 'setup') loadSetup();
171
+ setTimeout(function () { state.ingesting = null; if (state.view === 'knowledge') render(); }, 60000);
172
+ if (state.view === 'knowledge') render();
173
+ });
174
+ }
175
+ function tick() {
176
+ ticks++;
177
+ var reads = [call('graph.progress').then(function (d) { state.progress = d; }, function () {})];
178
+ if (ticks % 3 === 0) reads.push(loadGraphStatus());
179
+ Promise.all(reads).then(function () {
180
+ var pr = state.progress || {}, g = state.graphStatus || {}, lk = pr.lock || g.lock || {};
181
+ var held = pr.running === true || lk.state === 'exclusive' || lk.state === 'shared';
182
+ if (!held || ticks > 2880) return finish();
183
+ if (state.view === 'knowledge') render();
184
+ });
185
+ }
186
+ state.ingestWatch = setInterval(tick, 5000); tick();
187
+ }
188
+ // The run as it stands: the Sync card and the setup's sample step both show it.
189
+ function progressBlock() {
190
+ var pr = state.progress, g = state.graphStatus || {}, lk = (pr && pr.lock) || g.lock || {};
191
+ var held = (pr && pr.running === true) || lk.state === 'exclusive' || lk.state === 'shared';
192
+ if (!held) return '';
193
+ if (!pr) return '<div class="ingest-progress"><b>Indexing now</b><span class="muted"> · reading progress…</span></div>';
194
+ var head, body = '';
195
+ if (pr.external) head = '<b>Indexing now from another session</b>' + (pr.pid ? ' (pid ' + esc(pr.pid) + ')' : '') + ' · ' + fmt(pr.pending || 0) + ' pending · progress detail shows for runs started here';
196
+ else {
197
+ var seen = pr.done + pr.failed + (pr.current ? 1 : 0);
198
+ head = '<b>Indexing ' + (pr.total != null ? fmt(Math.min(seen, pr.total)) + ' of ' + fmt(pr.total) : 'now') + '</b>' + (pr.pid ? ' (pid ' + esc(pr.pid) + ')' : '') + ' · ' + fmt(pr.pending || 0) + ' pending';
199
+ if (pr.current) body += '<div class="muted">Now: ' + esc(pr.current.id) + (pr.current.est_calls != null ? ' · about ' + fmt(pr.current.est_calls) + ' calls' : '') + '</div>';
200
+ }
201
+ if (pr.items && pr.items.length) body += '<div class="ingest-items">' + pr.items.slice(-4).map(function (it) { return '<span class="' + (it.outcome === 'indexed' ? 'ok' : it.outcome === 'failed' ? 'bad' : '') + '">' + (it.outcome === 'indexed' ? '✓ ' : it.outcome === 'failed' ? '! ' : '') + esc(it.id) + (it.seconds != null ? ' ' + Math.round(it.seconds) + ' s' : '') + '</span>'; }).join('') + '</div>';
202
+ if (pr.budget) body += '<div class="muted">Budget: ' + fmt(pr.budget.used) + ' of ' + fmt(pr.budget.cap) + ' calls today</div>';
203
+ if (pr.log_tail && pr.log_tail.length) body += '<details class="ingest-log"><summary>Show log</summary><pre>' + esc(pr.log_tail.join('\n')) + '</pre></details>';
204
+ return '<div class="ingest-progress" aria-live="polite"><div>' + head + '</div>' + body + '</div>';
205
+ }
206
+ function loadGraphStatus() {
207
+ return call('graph.status').then(function (d) { state.graphStatus = d; state.errors.graph = null; var lk = d.lock || {}; if (lk.state === 'exclusive' || lk.state === 'shared') watchIngest(); if (d.entities == null && !state.knowledgeTabChosen) state.knowledgeTab = 'setup'; render(); }, function (e) { state.errors.graph = e.message; render(); });
208
+ }
209
+ function loadLearning(more) {
210
+ if(more&&state.learningPageLoading)return Promise.resolve();
211
+ var serial=++learningRequestSerial;state.learningPageLoading=true;
212
+ return call('workspace.request',{action:'learning_page',days:90,limit:50,cursor:more?state.recentCursor:null}).then(function(d){
213
+ if(serial!==learningRequestSerial)return;
214
+ if(d.protocol!==1||!Array.isArray(d.items))throw new Error('Snapshot browsing unavailable in this version.');
215
+ state.learningPageCapable=true;state.recent=more?state.recent.concat(d.items):d.items;seedDecisions(d.items);
216
+ state.recentTotal=d.matched_total;state.recentCursor=d.next_cursor;state.coverage=d.coverage||{};
217
+ state.learningStatus=d;render();
218
+ }).catch(function(error){
219
+ if(serial!==learningRequestSerial)return;
220
+ if(more&&state.learningPageCapable){toast(error.message+' Refresh recent learning to start a new snapshot.');return;}
221
+ state.learningPageCapable=false;
222
+ var args={days:90,limit:50};if(more){args.sinceTs=state.recentCursor.since_ts;args.sinceEventId=state.recentCursor.since_event_id;}
223
+ return call('learning.list',args).then(function(d){
224
+ if(serial!==learningRequestSerial)return;
225
+ state.recent=more?state.recent.concat(d.events||[]):d.events||[];seedDecisions(d.events);state.recentTotal=d.total;
226
+ state.recentCursor=d.nextCursor;state.coverage=d.coverage||{};
227
+ state.learningStatus={counts_by_type:{},stores:{},complete:false,notice:'Activity totals unavailable in this server version. The list uses a 90-day window.'};render();
228
+ },function(e){if(serial!==learningRequestSerial)return;state.errors.recent=e.message;render();});
229
+ }).finally(function(){if(serial===learningRequestSerial)state.learningPageLoading=false;});
230
+ }
231
+ function loadMore() {if(state.recentCursor)loadLearning(true);}
232
+ // ── Applied this week (0.5.216) ─────────────────────────────────
233
+ // The helper's `--kind used` window, never the first Recent page filtered
234
+ // in JS: a use that is not on page 1 must still be listed. Fails closed:
235
+ // an error or an unreadable trace leaves the list empty with its reason.
236
+ var appliedRequestSerial=0;
237
+ function loadApplied(more){
238
+ if(more&&(state.appliedLoading||!state.appliedCursor))return Promise.resolve();
239
+ var serial=++appliedRequestSerial;state.appliedLoading=true;
240
+ var args={kind:'used',days:state.appliedDays,limit:50};
241
+ if(more){args.sinceTs=state.appliedCursor.since_ts;args.sinceEventId=state.appliedCursor.since_event_id;}
242
+ return call('learning.list',args).then(function(d){
243
+ if(serial!==appliedRequestSerial)return;
244
+ var events=d.events||[];
245
+ // Fail closed: a server that ignored `kind` returns the whole window. Never label that Applied.
246
+ if(events.some(function(e){return e.event_type!=='used';})){state.applied=[];state.appliedTotal=null;state.appliedCursor=null;state.appliedLoaded=true;state.errors.applied='This server returned unfiltered learning rows. Applied stays empty rather than mislabel them.';render();return;}
247
+ var seen={};state.applied.forEach(function(e){seen[e.event_id]=true;});
248
+ var rows=events.filter(function(e){if(seen[e.event_id])return false;seen[e.event_id]=true;return true;});
249
+ state.applied=more?state.applied.concat(rows):rows;state.appliedTotal=d.total;state.appliedCursor=d.nextCursor;
250
+ state.appliedCoverage=d.coverage||{};state.appliedLoaded=true;state.errors.applied=null;
251
+ state.applied.forEach(function(e){if(e.lesson_id)ensureMemoryDetail(e.lesson_id);});
252
+ render();
253
+ },function(e){if(serial!==appliedRequestSerial)return;state.errors.applied=e.message;state.appliedLoaded=true;render();})
254
+ .finally(function(){if(serial===appliedRequestSerial)state.appliedLoading=false;});
255
+ }
256
+ function setAppliedDays(days){state.appliedDays=days===90?90:7;state.applied=[];state.appliedCursor=null;state.appliedTotal=null;state.appliedLoaded=false;loadApplied(false);render();}
257
+ var reviewRequestSerial=0;
258
+ function loadReview(more){
259
+ if(more&&state.reviewLoading)return;var serial=++reviewRequestSerial;state.reviewLoading=true;
260
+ return call('workspace.request',{action:'review_page',limit:25,cursor:more?state.reviewCursor:null}).then(function(d){
261
+ if(serial!==reviewRequestSerial)return;
262
+ state.review=more?state.review.concat(d.items):d.items;seedDecisions(d.items);state.reviewCount=d.matched_total;state.reviewCursor=d.next_cursor;render();
263
+ }).catch(function(e){
264
+ if(serial!==reviewRequestSerial)return;if(more){toast(e.message+' Refresh review to start a new snapshot.');return;}
265
+ return call('learning.review',{limit:200}).then(function(d){if(serial!==reviewRequestSerial)return;state.review=d.events||[];state.reviewCount=d.reviewCount!=null?d.reviewCount:d.total;render();},function(error){state.errors.review=error.message;render();});
266
+ }).finally(function(){if(serial===reviewRequestSerial)state.reviewLoading=false;});
267
+ }
268
+ function reviewGoverned(id,decision){
269
+ if(state.memoryReviewBusy)return;
270
+ var row=state.review.concat(state.recent).find(function(e){return e.lesson_id===id&&e.governance;}),intent=state.reviewIntent;
271
+ if(row&&state.detail[row.event_id]&&state.detail[row.event_id].journal_revision)row=Object.assign({},row,state.detail[row.event_id]);
272
+ if(!row)throw new Error('Refresh the review list before changing this memory.');
273
+ if(decision==='forget'&&state.armed!=='forget:'+id){state.armed='forget:'+id;toast('Click Forget permanently again. Searchable content will be removed; external sources and protected backups are reported separately.');render();return;}
274
+ if(!intent)intent=state.reviewIntent={action:'review_memory',id:id,decision:decision,expected_revision:row.journal_revision,idempotency_key:'review_'+crypto.randomUUID()};
275
+ if(intent.id!==id||intent.decision!==decision){toast('Retry the pending review action before starting another.');return;}
276
+ state.memoryReviewBusy=id;render();
277
+ call('workspace.request',intent).then(function(d){
278
+ state.memoryReviewBusy=null;state.reviewIntent=null;state.armed=null;state.detail={};state.memoryDetail={};
279
+ toast(d.status==='deletion_pending_projection'?'Forgotten content is suppressed; projection cleanup is pending.':decision==='forget'?'Searchable memory removed. External sources and protected backups remain.':'Memory '+(d.state||d.status)+'.');
280
+ loadReview(false);loadLearning(false);loadMemoryPage(false);
281
+ },function(e){state.memoryReviewBusy=null;if(/revision_conflict|idempotency_conflict|memory_not_found/.test(e.message)){state.reviewIntent=null;state.detail={};state.memoryDetail={};loadReview(false);loadLearning(false);loadMemoryPage(false);}toast(e.message);render();});
282
+ }
283
+ function ensureDetail(id) {
284
+ if (!id || state.detail[id] || state.loading[id]) return;
285
+ state.loading[id] = true;
286
+ var governed=state.review.concat(state.recent).find(function(e){return e.event_id===id&&e.governance;});
287
+ if(governed){call('workspace.request',{action:'get_memory',id:governed.lesson_id}).then(function(d){state.detail[id]={detail:{content:d.item.payload.content},governance:d.item.state,journal_revision:d.item.revision};delete state.loading[id];render();},function(e){state.detail[id]={_error:e.message};delete state.loading[id];render();});return;}
288
+ call('learning.event', { id: id }).then(function (d) { state.detail[id] = d; delete state.loading[id]; render(); }, function (e) { state.detail[id] = { _error: e.message }; delete state.loading[id]; render(); });
289
+ }
290
+ function ensureMemoryDetail(id) {
291
+ if (!id || state.memoryDetail[id] || state.loading['m:' + id]) return;
292
+ state.loading['m:' + id] = true;
293
+ call('memory.detail', { id: id }).then(function (d) { state.memoryDetail[id] = d; delete state.loading['m:' + id]; render(); }, function (e) { state.memoryDetail[id] = { _error: e.message }; delete state.loading['m:' + id]; render(); });
294
+ }
295
+
296
+ // ── rows ────────────────────────────────────────────────────────
297
+ var KIND = { captured: 'Captured', proposed: 'Proposed', promotable: 'Promotable pattern', saved: 'Saved', retrieved: 'Retrieved', used: 'Application recorded', included:'Included in context', applied:'Application evidence', checked: 'Checked', dismissed: 'Dismissed', reverted: 'Reverted', reopened: 'Reopened', consolidated: 'Consolidated', previewed: 'Previewed', accepted: 'Accepted', pruned: 'Pruned' };
298
+ var STORE = { self_improvement_queue: 'Self-improvement queue', correction_journal: 'Correction journal', bot_memory: 'Bot memory', review_ledger: 'Review ledger', git_versions: 'Skill versions', eval_scores: 'Eval scores', reflect_log: 'Reflect log', capture_ledger: 'Capture ledger' };
299
+ function isProposal(e) { return e && e.target && e.target.kind === 'task-proposal'; }
300
+ function isPattern(e) { return e && (e.event_type === 'promotable' || (e.target && e.target.kind === 'pattern')); }
301
+ // A review decision survives a reload (0.5.257): the ledger's pruned/accepted events seed the map memoryActions
302
+ // reads. Before, a reload forgot every prune and offered Accept and Prune on a memory already out of recall.
303
+ // The newest decision per memory wins; one made in this session is stamped now, so a stale seed never undoes it.
304
+ function seedDecisions(rows) {
305
+ state.decisions = state.decisions || {}; state.decisionTs = state.decisionTs || {};
306
+ (rows || []).forEach(function (e) {
307
+ if (!e || e.store !== 'review_ledger' || !e.lesson_id || (e.event_type !== 'pruned' && e.event_type !== 'accepted')) return;
308
+ var ts = String(e.ts || '');
309
+ if (state.decisionTs[e.lesson_id] && state.decisionTs[e.lesson_id] >= ts) return;
310
+ state.decisionTs[e.lesson_id] = ts; state.decisions[e.lesson_id] = e.event_type;
311
+ });
312
+ }
313
+ // Prune ready (0.5.257): memories `bot_memory.py prune` would quarantine now, listed in To review by the bridge
314
+ // as captured memories whose category starts with this label and carries the reason.
315
+ var PRUNE_READY = 'Prune ready';
316
+ function isPruneReady(e) { return !!e && e.store === 'bot_memory' && typeof e.category === 'string' && e.category.indexOf(PRUNE_READY) === 0; }
317
+ function pruneReadyReason(e) { var c = String(e.category || ''); return c.indexOf(' · ') > 0 ? c.slice(c.indexOf(' · ') + 3) : ''; }
318
+ function kindLabel(e) { if (e && e.store === 'bot_memory' && e.event_type === 'captured' && latestDecisionFor(e) === 'pruned') return 'Quarantined'; if (isPruneReady(e) && !latestDecisionFor(e)) return PRUNE_READY; if (isProposal(e)) return 'Task proposal'; if (isPattern(e)) return 'Promotable pattern'; return KIND[e.event_type] || e.event_type || 'Event'; }
319
+ function outcomeText(e) { var o = e && e.outcome; if (!o) return null; if (typeof o === 'string') return o; return o.result ? (o.result + (o.name ? ' · ' + o.name : '')) : null; }
320
+ function rowStatus(e) {
321
+ var o = outcomeText(e);
322
+ if (o) { var result=typeof e.outcome==='object'?e.outcome.result:e.outcome; return { text:'Checked: '+o, color: /^(pass|passed|success)$/.test(result)?'green':/^(fail|failed)$/.test(result)?'bad':'' }; }
323
+ if (isProposal(e) || isPattern(e)) return { text: 'Needs your review', color: '' };
324
+ if (e.event_type === 'dismissed') return { text: 'Dismissed', color: '' };
325
+ if (e.event_type === 'retrieved') return {text:'Retrieved · application unverified',color:''};
326
+ if (e.event_type === 'included') return {text:'Included in context · application unverified',color:''};
327
+ if (e.event_type === 'applied' || e.event_type === 'used') return {text:'Application evidence recorded',color:''};
328
+ return { text: 'Saved · No later use', color: '' };
329
+ }
330
+ function lessonRow(e, selected) {
331
+ var st = rowStatus(e);
332
+ var meta = [STORE[e.store] || e.store, e.scope, stamp(e.ts)].filter(Boolean).join(' · ');
333
+ return '<button class="lesson ' + (selected ? 'selected' : '') + '" onclick="cosApp.select(' + attr(e.event_id) + ')" aria-pressed="' + (selected ? 'true' : 'false') + '"><div class="kind">' + esc(kindLabel(e)) + '</div><span class="title">' + esc(e.title || '(untitled)') + '</span><div class="meta">' + esc(meta) + '</div><span class="status ' + st.color + '">' + esc(st.text) + '</span></button>';
334
+ }
335
+ function memoryRow(m, selected) {
336
+ var title = m.summary || m.content || '(untitled)';
337
+ var meta = [m.type, dateOnly(m.created_at)].filter(Boolean).join(' · ');
338
+ return '<button class="lesson ' + (selected ? 'selected' : '') + '" onclick="cosApp.selectMemory(' + attr(m.id) + ')" aria-pressed="' + (selected ? 'true' : 'false') + '"><div class="kind">Memory</div><span class="title">' + esc(String(title).slice(0, 140)) + '</span><div class="meta">' + esc(meta) + '</div></button>';
339
+ }
340
+
341
+ // ── summary and nav ─────────────────────────────────────────────
342
+ // Needs-you (gated, cap 7). Never review_page matched_total / SIQ to_review.
343
+ function needsYouCount() {
344
+ var s = state.status || {}, ls = state.learningStatus || {}, ny = ls.needs_you || s.needs_you;
345
+ if (Array.isArray(state.needsYou)) return state.needsYou.length;
346
+ if (Array.isArray(s.needsYou)) return s.needsYou.length;
347
+ if (ny && Array.isArray(ny.items)) return ny.items.length;
348
+ if (ny && ny.count != null) return ny.limit != null ? Math.min(Number(ny.count), Number(ny.limit)) : Number(ny.count);
349
+ if (s.learningNeedsYou != null) return s.learningNeedsYou;
350
+ if (s.learningToReview != null) return s.learningToReview;
351
+ return null;
352
+ }
353
+ // Reward is a ranking tiebreaker inside the startup hook. The page may say a
354
+ // lesson moved ranking only on the projector's literal flag, and never a number.
355
+ function rewardEnabled() { var s = state.status || {}; return s.learningRewardEnabled === true; }
356
+ // One sentence, one place. Present tense, no magnitude: the hook applies a bounded
357
+ // tiebreaker, not an observed reorder, so the page never says a rank 'moved'.
358
+ function rankingSentence() { return rewardEnabled() ? '<p>This lesson now ranks ahead of unused memories like it in later recall.</p>' : ''; }
359
+ // The parsed count travels in the projector title (list rows carry no detail block).
360
+ function appliedCountFromTitle(e) { var m = /Applied\s+([\d,]+)\s+time/.exec(String((e && e.title) || '')); return m ? Number(m[1].replace(/,/g, '')) : null; }
361
+ function bridgeConnected() { var s = state.status || {}; return !!s.contextScriptsDirectory; }
362
+ // The memory path this install chose. Only the COS Data bridge (vector store)
363
+ // has an `applied` detector; files, document links, and Knowledge do not.
364
+ function memoryPath() {
365
+ var s = state.status || {}, g = state.graphStatus || {};
366
+ if (bridgeConnected()) return 'bridge';
367
+ if (g.entities != null || g.engine) return 'knowledge';
368
+ return 'files';
369
+ }
370
+ function memoryNav() {
371
+ var review = needsYouCount();
372
+ document.querySelector('#workspace').classList.toggle('knowledge', state.view === 'knowledge');
373
+ document.querySelector('#memoryNav').innerHTML = [['recent', 'Recent learning'], ['applied', 'Applied this week'], ['memories', 'All memories'], ['review', 'To review' + (review ? ' (' + fmt(review) + ')' : '')]].map(function (p) {
374
+ var on = state.view !== 'knowledge' && state.filter === p[0];
375
+ return '<button class="' + (on ? 'active' : '') + '" aria-pressed="' + on + '" onclick="cosApp.setFilter(\'' + p[0] + '\')">' + p[1] + '</button>';
376
+ }).join('') + (state.cues ? '<button onclick="cosApp.openCues(\'rate\')">Cues to rate' + (typeof state.cues.unrated === 'number' && state.cues.unrated > 0 ? ' (' + fmt(state.cues.unrated) + ')' : '') + '</button>' +
377
+ '<button onclick="cosApp.openCues(\'rated\')">Rated cues</button>' : '') +
378
+ '<button class="' + (state.view === 'knowledge' ? 'active' : '') + '" aria-pressed="' + (state.view === 'knowledge') + '" onclick="cosApp.openKnowledge()">Knowledge</button>';
379
+ }
380
+ function summary() {
381
+ var review = needsYouCount();
382
+ var checked = state.recent.filter(function (e) { return outcomeText(e); }).length;
383
+ var parts = [];
384
+ if (review) parts.push('<span><strong>' + fmt(review) + ' change' + (review === 1 ? '' : 's') + '</strong> to review</span>');
385
+ if (state.recentTotal != null) parts.push('<span><strong>' + fmt(state.recentTotal) + ' event' + (state.recentTotal === 1 ? '' : 's') + '</strong> in 90 days</span>');
386
+ parts.push('<span><strong>' + fmt(checked) + ' result' + (checked === 1 ? '' : 's') + '</strong> checked on this page</span>');
387
+ parts.push('<span>Capture status in settings · <button class="link" onclick="cosApp.openLog()">View activity log</button></span>');
388
+ document.querySelector('#summary').innerHTML = parts.join('');
389
+ var s = state.status || {};
390
+ document.querySelector('#storage').textContent = state.status ? ('Stored on this Mac · ' + (bridgeConnected() ? 'Advanced search connected' : 'Plain files')) : 'Stored on this Mac';
391
+ document.querySelector('#footnote').textContent = state.status ? ('Server ' + (s.installedVersion || '') + ' · ' + (s.runtimeState || '')).trim() : '';
392
+ }
393
+
394
+ // ── render ──────────────────────────────────────────────────────
395
+ function render() {
396
+ summary(); memoryNav();
397
+ if (state.view === 'knowledge') { renderKnowledge(); return; }
398
+ var inbox = document.querySelector('#inbox'), detail = document.querySelector('#detail');
399
+ document.querySelector('#workspace').style.gridTemplateColumns = ''; inbox.classList.remove('hidden');
400
+ if (state.filter === 'memories') { renderMemories(inbox, detail); return; }
401
+ if (state.filter === 'applied') { renderApplied(inbox, detail); return; }
402
+ var rows = state.filter === 'review' ? state.review : state.recent;
403
+ var err = state.filter === 'review' ? state.errors.review : state.errors.recent;
404
+ var loading = state.filter === 'review' ? (state.reviewCount == null && !err) : (state.recentTotal == null && !err);
405
+ if (rows.length && !rows.some(function (x) { return x.event_id === state.selected; })) state.selected = rows[0].event_id;
406
+ inbox.innerHTML = '<div class="date">' + (state.filter === 'recent' ? 'RECENT LEARNING · 90 DAYS' : 'PROPOSED CHANGES') + '</div>' +
407
+ (err ? '<div class="host-state"><div><h3>Not available</h3><p>' + esc(err) + '</p><button onclick="cosApp.refresh()">Retry</button></div></div>' : '') +
408
+ (loading ? '<div class="host-state">Loading…</div>' : '') +
409
+ (state.filter === 'review' ? reviewRows(rows) : rows.map(function (e) { return lessonRow(e, e.event_id === state.selected); }).join('')) +
410
+ (state.filter === 'recent' && state.recentCursor ? '<div class="actions"><button class="quiet" onclick="cosApp.loadMore()">Load more</button></div>' : '') +
411
+ (state.filter==='review'&&state.reviewCursor?'<button onclick="cosApp.loadMoreReview()">Load more review items</button>':'')+coverageNote();
412
+ if (!rows.length) {
413
+ detail.innerHTML = loading ? '<div class="host-state">Loading…</div>' : (err ? '' : '<div class="empty"><div><h2>You’re up to date.</h2><p>' + (state.filter === 'review' ? 'No proposed changes are waiting for review.' : 'Nothing learned in the last 90 days.') + '</p>' + (state.filter === 'review' ? '<button onclick="cosApp.setFilter(\'recent\')">View recent learning</button>' : '') + '</div></div>');
414
+ return;
415
+ }
416
+ var event = rows.find(function (x) { return x.event_id === state.selected; });
417
+ ensureDetail(event.event_id);
418
+ var full = state.detail[event.event_id];
419
+ detail.innerHTML = detailActions(event) + lessonDetail(Object.assign({}, event, full && !full._error ? full : {}), full);
420
+ }
421
+ function reviewRows(rows) {
422
+ var other = rows.filter(function (e) { return !isPruneReady(e); }), ready = rows.filter(isPruneReady);
423
+ var open = ready.filter(function (e) { return !latestDecisionFor(e); }).length, b = state.bulkPrune;
424
+ var head = !ready.length ? '' : '<div class="date">' + esc(PRUNE_READY.toUpperCase()) + ' · ' + (open ? fmt(open) : 'ALL DECIDED') + '</div>' +
425
+ (b ? '<p class="muted" style="padding:0 9px">Quarantining ' + fmt(b.done) + ' of ' + fmt(b.total) + '…</p>' :
426
+ open ? '<div class="actions" style="padding:0 9px 8px"><button class="quiet" onclick="cosApp.pruneAllReady()">' +
427
+ (state.armed === 'prune-ready' ? 'Click again to quarantine ' + fmt(open) : 'Quarantine all ' + fmt(open)) + '</button></div>' : '');
428
+ var line = function (e) { return lessonRow(e, e.event_id === state.selected); };
429
+ return other.map(line).join('') + head + ready.map(line).join('');
430
+ }
431
+ function coverageNote() {
432
+ var gaps = Object.keys(state.coverage || {}).filter(function (k) { return state.coverage[k] && state.coverage[k].state !== 'ok'; });
433
+ if (!gaps.length) return '';
434
+ return '<p class="muted" style="font-size:10px;padding:10px 9px 0">Not instrumented: ' + esc(gaps.map(function (k) { return k + ' (' + state.coverage[k].state + ')'; }).join(', ')) + '</p>';
435
+ }
436
+
437
+ // ── lesson detail (the reviewed timeline) ───────────────────────
438
+ function event(title, meta, body, done, last) { return '<section class="event ' + (done !== false ? 'complete' : '') + ' ' + (last ? 'last' : '') + '"><div class="row spread"><h3>' + title + '</h3><span class="meta">' + meta + '</span></div>' + body + '</section>'; }
439
+ function detailActions(e) {
440
+ return '<div class="context-actions row spread"><span class="eyebrow">' + esc(STORE[e.store] || e.store || '') + '</span><div class="row"><button class="quiet" onclick="cosApp.openKnowledge(' + attr(e.event_id) + ')">Open sources</button><button class="quiet" onclick="cosApp.exploreInGraph(' + attr(e.event_id) + ')">Explore in graph</button><button onclick="cosApp.openCopyContext(' + attr(e.event_id) + ')">Copy context</button></div></div>';
441
+ }
442
+ function sourceEvent(e) {
443
+ var ref = (e.source_refs || [])[0] || {};
444
+ var d = e.detail || {};
445
+ var quote = ref.excerpt || d.content || (d.bodies || [])[0] || '';
446
+ var title = e.store === 'correction_journal' ? 'You corrected COS' : e.store === 'self_improvement_queue' ? 'A task proposed a change' : e.store === 'bot_memory' ? 'COS captured this' : e.store === 'eval_scores' || e.store === 'reflect_log' ? 'A run was checked' : 'Captured';
447
+ var open = e.store === 'bot_memory' && e.lesson_id ? '<button class="link" onclick="cosApp.openMemoryRecord(' + attr(e.lesson_id) + ')">Open source record</button>' : (ref.id ? '<span class="meta">' + esc(ref.kind || '') + ' · ' + esc(ref.id) + '</span>' : '');
448
+ return event(title, esc(stamp(e.ts) || 'Source'), (quote ? '<div class="quote">' + esc(quote) + '</div>' : '<p>No excerpt was stored with this record.</p>') + open + memoryActions(e));
449
+ }
450
+ // Accept activates; quality quarantine retains content. Forget is a separate explicit action.
451
+ // Both become timeline events; nothing else in the store is touched (0.5.201).
452
+ function memoryActions(e) {
453
+ if(e.governance){var busy=state.memoryReviewBusy===e.lesson_id;
454
+ return '<p>State: '+esc(e.governance)+' · revision '+esc(e.journal_revision)+'</p><div class="row">'+
455
+ [['accept','Accept for recall'],['quarantine','Quarantine'],['restore','Restore to recall'],['forget','Forget permanently']].map(function(pair){
456
+ if(pair[0]==='accept'&&e.governance!=='candidate'||pair[0]==='restore'&&e.governance!=='quarantined'||pair[0]==='quarantine'&&e.governance==='quarantined')return '';
457
+ return '<button '+(busy?'disabled':'')+' onclick="cosApp.reviewGoverned('+attr(e.lesson_id)+','+attr(pair[0])+')">'+pair[1]+'</button>';
458
+ }).join('')+'</div>';
459
+ }
460
+ if (e.store !== 'bot_memory' || !e.lesson_id || e.event_type !== 'captured') return '';
461
+ var decision = latestDecisionFor(e);
462
+ var busy = state.memoryReviewBusy === e.lesson_id;
463
+ if (decision === 'pruned') return '<p class="muted">Quarantined from active recall. Content is retained for review.</p>' +
464
+ '<div class="actions"><button ' + (busy ? 'disabled' : '') + ' onclick="cosApp.reviewMemory(' + attr(e.lesson_id) + ', \'restore\')">' + (busy ? 'Working…' : 'Restore to recall') + '</button></div>';
465
+ var armed = state.armed === 'prune:' + e.lesson_id;
466
+ return (isPruneReady(e) && !decision ? '<p class="muted">' + esc(PRUNE_READY) + (pruneReadyReason(e) ? ': ' + esc(pruneReadyReason(e)) : '') + '. Accept keeps it in recall and stops it being proposed again; Prune takes it out of recall, and Restore brings it back.</p>' : '') +
467
+ '<div class="actions">' +
468
+ (decision === 'accepted' ? '<span class="pill ok">Accepted</span>' : '<button ' + (busy ? 'disabled' : '') + ' onclick="cosApp.reviewMemory(' + attr(e.lesson_id) + ', \'accept\')">' + (busy ? 'Working…' : 'Accept') + '</button>') +
469
+ '<button class="quiet" ' + (busy ? 'disabled' : '') + ' onclick="cosApp.reviewMemory(' + attr(e.lesson_id) + ', \'prune\')">' + (armed ? 'Click again to prune' : 'Prune') + '</button>' +
470
+ '</div>';
471
+ }
472
+ function changeEvent(e, full) {
473
+ var d = e.detail || {};
474
+ var proposal = isProposal(e), pattern = isPattern(e);
475
+ var applies = (e.applies_to || []).map(function (a) { return typeof a === 'string' ? a : [a.name || a.path || a.id, a.cadence].filter(Boolean).join(' · '); }).filter(Boolean);
476
+ var body = '';
477
+ if (proposal) {
478
+ body += '<p>Proposed by <b style="color:var(--fg)">' + esc(d.task || e.category || 'a scheduled task') + '</b>' + (d.layer ? ' · layer ' + esc(d.layer) : '') + (d.date ? ' · ' + esc(d.date) : '') + '.</p>';
479
+ body += (d.entry || d.content ? '<div class="quote">' + esc(d.entry || d.content) + '</div>' : '') + (d.bodies || []).map(function (b) { return '<p>' + esc(b) + '</p>'; }).join('');
480
+ if (d.logged_times) body += '<p>Logged ' + fmt(d.logged_times) + ' time' + (d.logged_times === 1 ? '' : 's') + '.</p>';
481
+ } else if (d.before || d.after || d.rule) {
482
+ body += '<div class="change">' + (d.before ? '<div><label>Before</label>' + esc(d.before) + '</div>' : '') + '<div class="after"><label>' + (d.after ? 'After' : 'Rule') + '</label>' + esc(d.after || d.rule || '') + '</div></div>';
483
+ if (d.rule && d.after) body += '<p style="margin-top:8px"><b style="color:var(--fg)">Rule</b> ' + esc(d.rule) + '</p>';
484
+ } else if (d.content) {
485
+ body += '<div class="quote">' + esc(d.content) + '</div>';
486
+ } else {
487
+ body += '<p>' + esc(e.title || '') + '</p>';
488
+ }
489
+ if (e.target && e.target.version) body += '<p>Version <b style="color:var(--fg)">' + esc(e.target.version) + '</b></p>';
490
+ body += effectBlock(e, applies);
491
+ var decision = latestDecisionFor(e);
492
+ var actions = '';
493
+ if (proposal || pattern) {
494
+ var dismissed = decision === 'dismissed';
495
+ actions = '<div class="actions">' +
496
+ '<button class="primary" onclick="cosApp.openStewardship()">Review rule versions</button>' +
497
+ '<button disabled title="Editing a proposed instruction arrives with skill versioning.">Edit</button>' +
498
+ (dismissed
499
+ ? '<button onclick="cosApp.decide(' + attr(e.event_id) + ',\'reopened\')">Restore proposal</button>'
500
+ : '<button class="quiet" onclick="cosApp.decide(' + attr(e.event_id) + ',\'dismissed\')">Dismiss</button>') +
501
+ '</div>';
502
+ }
503
+ var title = decision === 'dismissed' ? 'Change dismissed' : proposal ? 'Proposed change' : e.event_type === 'promotable' ? 'Promotable pattern' : (KIND[e.event_type] || 'Change');
504
+ return event(title, proposal ? 'Instruction' : esc(e.engine || e.scope || ''), body + actions);
505
+ }
506
+ function latestDecisionFor(e) { return state.decisions && state.decisions[e.lesson_id]; }
507
+ function isUseType(e) {
508
+ return e && (e.event_type === 'used' || e.event_type === 'checked' || e.event_type === 'retrieved' || e.event_type === 'included' || e.event_type === 'applied');
509
+ }
510
+ function relatedUseRows(e) {
511
+ var id = e && e.lesson_id, rows = [], seen = {};
512
+ function take(row) {
513
+ if (!row || seen[row.event_id] || !isUseType(row)) return;
514
+ if (id && row.lesson_id === id) { seen[row.event_id] = true; rows.push(row); }
515
+ }
516
+ (state.recent || []).forEach(take);
517
+ (state.review || []).forEach(take);
518
+ if (isUseType(e) && !seen[e.event_id]) rows.push(e);
519
+ rows.sort(function (a, b) { return String(a.ts || '').localeCompare(String(b.ts || '')); });
520
+ return rows;
521
+ }
522
+ function effectBlock(e, applies) {
523
+ var d = e.detail || {}, o = outcomeText(e), rows = [];
524
+ var repeat = d.occurrences != null
525
+ ? (fmt(d.occurrences) + ' occurrence' + (d.occurrences === 1 ? '' : 's') + (d.threshold ? ' (threshold ' + d.threshold + ')' : ''))
526
+ : (d.logged_times ? ('logged ' + fmt(d.logged_times) + ' time' + (d.logged_times === 1 ? '' : 's')) : null);
527
+ if (applies.length) rows.push('<li><b>Where it applies</b>' + esc(applies.join(', ')) + '</li>');
528
+ if (o || d.status) rows.push('<li><b>Checks</b>' + esc(o || d.status) + '</li>');
529
+ if (repeat) rows.push('<li><b>Repeat rate</b>' + esc(repeat) + '</li>');
530
+ if (!rows.length) return '';
531
+ return '<div class="effect"><div class="row spread"><label>Expected effect</label></div><ul class="effect-list">' + rows.join('') + '</ul></div>';
532
+ }
533
+ function useEventBody(row) {
534
+ var o = outcomeText(row);
535
+ var stage = (row.detail && row.detail.stage) || row.event_type;
536
+ var meta = esc(row.engine || (row.outcome && row.outcome.evaluator) || stage || '');
537
+ if (o || row.event_type === 'checked' || stage === 'checked') {
538
+ return event('A later result was checked', row.outcome && row.outcome.evaluator ? esc(row.outcome.evaluator) : 'Checked', '<div class="check">' + esc(o || row.title || 'Checked') + '</div><p>One recorded check. Evidence for that run, not a guarantee about every future answer.</p>');
539
+ }
540
+ if (row.event_type === 'used' || stage === 'applied') {
541
+ var excerpt = ((row.source_refs || [])[0] || {}).excerpt || (row.detail && row.detail.excerpts && row.detail.excerpts[0]) || '';
542
+ return event('Recorded use in a later session', meta, (excerpt ? '<div class="quote">' + esc(excerpt) + '</div>' : '') + '<p>This record was used by ' + esc(row.engine || 'an engine') + (row.detail && row.detail.count ? ' · ' + fmt(row.detail.count) + ' time' + (row.detail.count === 1 ? '' : 's') : '') + '.</p>'
543
+ + rankingSentence());
544
+ }
545
+ var title = stage === 'included' ? 'Included in a later session' : 'Retrieved in a later session';
546
+ return event(title, meta, '<p>This record was ' + esc(stage || row.event_type) + ' by ' + esc(row.engine || 'an engine') + (row.detail && row.detail.count ? ' · ' + fmt(row.detail.count) + ' time' + (row.detail.count === 1 ? '' : 's') : '') + '.</p>');
547
+ }
548
+ function useEvent(e) {
549
+ var rows = relatedUseRows(e);
550
+ if (!rows.length) return '';
551
+ return rows.map(useEventBody).join('');
552
+ }
553
+ function evidenceEvent(e) {
554
+ var n = (e.source_refs || []).length;
555
+ return event('Evidence for this lesson', 'Knowledge', '<p>' + fmt(n) + ' source record' + (n === 1 ? '' : 's') + '. Sources are what was actually said. Graph links are extracted context, not proof that the lesson worked.</p><div class="actions"><button onclick="cosApp.openKnowledge(' + attr(e.event_id) + ')">Open sources</button><button onclick="cosApp.exploreInGraph(' + attr(e.event_id) + ')">Explore in graph</button></div>' + (e.provenance && e.provenance !== 'complete' ? '<p>Provenance: ' + esc(e.provenance) + '.</p>' : ''), true, true);
556
+ }
557
+ function lessonDetail(e, full) {
558
+ var o = outcomeText(e);
559
+ var badge = o ? '<span class="badge">Checked: '+esc(o)+'</span>' : isProposal(e) ? '<span class="badge">Task proposal</span>' : isPattern(e) ? '<span class="badge">Promotable pattern</span>' : e.event_type === 'retrieved' || e.event_type === 'used' ? '<span class="badge purple">Retrieved in a later session</span>' : '<span class="badge">' + esc(kindLabel(e)) + '</span>';
560
+ var head = '<div class="row spread">' + badge + '<span class="meta">' + esc(e.event_id) + '</span></div><h2 style="margin-top:12px">' + esc(e.title || '(untitled)') + '</h2><p class="intro">' + esc([STORE[e.store] || e.store, e.scope !== 'unknown' ? e.scope : null, e.engine !== e.scope && e.engine !== 'unknown' ? e.engine : null].filter(Boolean).join(' · ')) + '</p>';
561
+ var note = full && full._error ? '<div class="notice">' + esc(full._error) + '</div>' : (!full ? '<p class="muted" style="font-size:11px">Loading the record…</p>' : '');
562
+ if (e.detail && e.detail.truncated) note += '<div class="notice">Record truncated to fit; the store holds more than shown here.</div>';
563
+ return head + note + '<div class="timeline">' + sourceEvent(e) + changeEvent(e, full) + useEvent(e) + evidenceEvent(e) + '</div>';
564
+ }
565
+
566
+ // ── all memories ────────────────────────────────────────────────
567
+ // ── applied this week ───────────────────────────────────────────
568
+ function appliedExcerpt(e) { return ((e.source_refs || [])[0] || {}).excerpt || (e.detail && e.detail.excerpts && e.detail.excerpts[0]) || ''; }
569
+ function lessonText(id) { var m = id && state.memoryDetail[id]; if (!m || m._error) return ''; return m.content || m.summary || ''; }
570
+ function lessonError(id) { var m = id && state.memoryDetail[id]; return m && m._error ? String(m._error) : ''; }
571
+ function clip(text, n) { text = String(text || ''); return text.length > n ? text.slice(0, n - 1).replace(/\s+\S*$/, '') + '…' : text; }
572
+ function appliedRow(e, selected) {
573
+ var lesson = lessonText(e.lesson_id) || e.lesson_id || '(lesson)';
574
+ var meta = [e.engine && e.engine !== 'unknown' ? e.engine : null, stamp(e.ts)].filter(Boolean).join(' · ');
575
+ return '<button class="lesson ' + (selected ? 'selected' : '') + '" onclick="cosApp.select(' + attr(e.event_id) + ')" aria-pressed="' + (selected ? 'true' : 'false') + '"><div class="kind">' + esc(KIND.used) + '</div><span class="title">' + esc(clip(lesson, 140)) + '</span><div class="meta">' + esc(meta) + '</div><span class="status green excerpt">' + esc(clip(appliedExcerpt(e), 120) || 'A later answer used this lesson') + '</span></button>';
576
+ }
577
+ // Empty copy by path. The bridge can detect use and simply saw none; the other
578
+ // paths cannot detect it at all, and the list must not be filled from elsewhere.
579
+ function appliedEmptyCopy() {
580
+ var cov = (state.appliedCoverage && state.appliedCoverage.used) || (state.coverage && state.coverage.used) || {};
581
+ if (state.errors.applied) return { h: 'Applied is not available right now.', p: state.errors.applied };
582
+ // No path verdict before COS Control has answered: a pending or failed status must
583
+ // never read as 'the bridge is off' on a Mac where it is on.
584
+ if (!state.status) return state.errors.status
585
+ ? { h: 'Could not read this Mac\'s status.', p: 'Applied says nothing about the memory path until COS Control answers. ' + state.errors.status }
586
+ : { h: 'Checking this install\'s memory path…', p: 'Applied says nothing about use until COS Control has answered.' };
587
+ var path = memoryPath();
588
+ if (path === 'files') return { h: 'This memory path cannot detect use.', p: 'Applied stays empty until the COS Data bridge (vector store) is on. Plain-file memories keep working.' };
589
+ if (path === 'knowledge') return { h: 'Knowledge citations are not lesson reward.', p: 'Applied is for bot-memory lessons. Turn on the COS Data bridge to detect when a later answer uses one.' };
590
+ if (cov.state === 'unavailable') return { h: 'The use trace could not be read on this Mac.', p: 'Nothing is invented here. Check the trace file under COS Data and refresh.' };
591
+ if (cov.state !== 'ok' && cov.state !== 'legacy') return { h: 'Use has not been recorded on this Mac yet.', p: 'The applied detector has not written to the trace. Nothing here is a measurement of zero.' };
592
+ return { h: 'No later answer has used a lesson ' + (state.appliedDays === 90 ? 'in 90 days' : 'this week') + '.', p: 'Retrieval without acknowledgment is not use. A lesson lands here when a later answer visibly applied it.' };
593
+ }
594
+ function appliedDetail(e) {
595
+ var lesson = lessonText(e.lesson_id), lessonErr = lessonError(e.lesson_id), excerpt = appliedExcerpt(e), d = e.detail || {};
596
+ var n = appliedCountFromTitle(e);
597
+ var count = n != null ? ('Recorded ' + fmt(n) + ' time' + (n === 1 ? '' : 's') + ' since first use') : 'Recorded';
598
+ var head = '<div class="row spread"><span class="badge green">' + esc(KIND.used) + '</span><span class="meta">' + esc(e.lesson_id || '') + '</span></div>' +
599
+ '<h2 style="margin-top:12px">' + esc(lesson ? clip(lesson, 200) : 'Lesson') + '</h2>' +
600
+ '<p class="intro">' + esc([e.engine && e.engine !== 'unknown' ? e.engine : null, d.sessions ? fmt(d.sessions) + ' session' + (d.sessions === 1 ? '' : 's') : null, stamp(e.ts)].filter(Boolean).join(' · ')) + '</p>';
601
+ var lessonBody = lesson ? '<div class="quote">' + esc(lesson) + '</div>' : lessonErr ? '<div class="notice">This lesson\'s record could not be read (' + esc(lessonErr) + '). The use below is still recorded.</div>' : '<p class="muted">Loading the record…</p>';
602
+ var timeline = event('The lesson', 'Bot memory', lessonBody + (e.lesson_id ? '<button class="link" onclick="cosApp.openMemoryRecord(' + attr(e.lesson_id) + ')">Open source record</button>' : ''));
603
+ timeline += event('A later answer used it', esc(stamp(e.ts)), (excerpt ? '<div class="quote">' + esc(excerpt) + '</div>' : '') + '<p>' + esc(count) + ' from the assistant\'s own words. An acknowledgment is evidence for that answer, not a guarantee about every future one.</p>');
604
+ if (rewardEnabled()) timeline += event('Ranking', 'Reward on', rankingSentence(), true, true);
605
+ return head + '<div class="timeline">' + timeline + '</div>';
606
+ }
607
+ function renderApplied(inbox, detail) {
608
+ var rows = state.applied, err = state.errors.applied, loading = !state.appliedLoaded && !err;
609
+ if (rows.length && !rows.some(function (x) { return x.event_id === state.selected; })) state.selected = rows[0].event_id;
610
+ var toggle = '<button class="link" onclick="cosApp.appliedDays(' + (state.appliedDays === 90 ? 7 : 90) + ')">' + (state.appliedDays === 90 ? 'Show this week' : 'Show 90 days') + '</button>';
611
+ inbox.innerHTML = '<div class="date row spread"><span>APPLIED · ' + (state.appliedDays === 90 ? '90 DAYS' : 'THIS WEEK') + (state.appliedTotal != null ? ' · ' + fmt(state.appliedTotal) : '') + '</span>' + toggle + '</div>' +
612
+ (err ? '<div class="host-state"><div><h3>Not available</h3><p>' + esc(err) + '</p><button onclick="cosApp.retryApplied()">Retry</button></div></div>' : '') +
613
+ (loading ? '<div class="host-state">Loading…</div>' : '') +
614
+ rows.map(function (e) { return appliedRow(e, e.event_id === state.selected); }).join('') +
615
+ (state.appliedCursor ? '<div class="actions"><button class="quiet" onclick="cosApp.loadMoreApplied()">Load more</button></div>' : '') +
616
+ coverageNote();
617
+ if (!rows.length) {
618
+ var copy = appliedEmptyCopy();
619
+ detail.innerHTML = loading ? '<div class="host-state">Loading…</div>' : '<div class="empty"><div><h2>' + esc(copy.h) + '</h2><p>' + esc(copy.p) + '</p>' + (err ? '<button onclick="cosApp.retryApplied()">Retry</button>' : '') + '</div></div>';
620
+ return;
621
+ }
622
+ var e = rows.find(function (x) { return x.event_id === state.selected; });
623
+ if (e.lesson_id) ensureMemoryDetail(e.lesson_id);
624
+ detail.innerHTML = '<div class="context-actions row spread"><span class="eyebrow">Applied this week</span><div class="row">' + (e.lesson_id ? '<button class="quiet" onclick="cosApp.openMemoryRecord(' + attr(e.lesson_id) + ')">Open source record</button>' : '') + '</div></div>' + appliedDetail(e);
625
+ }
626
+ function renderMemories(inbox, detail) {
627
+ var focusInput=inbox.querySelector('input[aria-label="Search memories"]');
628
+ var hadFocus=focusInput && document.activeElement===focusInput;
629
+ var selection=hadFocus?[focusInput.selectionStart,focusInput.selectionEnd]:null;
630
+ var rows = state.memoryHits != null ? state.memoryHits : state.memories;
631
+ if (rows.length && !rows.some(function (x) { return x.id === state.selectedMemory; })) state.selectedMemory = rows[0].id;
632
+ inbox.innerHTML = '<div class="date">SAVED MEMORIES' + (state.memoriesTotal != null ? ' · ' + fmt(state.memoriesTotal) + ' STORED' : '') + '</div>' +
633
+ '<div class="search"><input type="search" placeholder="Search topics, ideas…" value="' + esc(state.memoryQuery) + '" oninput="cosApp.memoryQuery(this.value)" aria-label="Search memories"></div>' +
634
+ (state.errors.memories ? '<div class="host-state"><div><h3>Not available</h3><p>' + esc(state.errors.memories) + '</p></div></div>' : '') +
635
+ rows.map(function (m) { return memoryRow(m, m.id === state.selectedMemory); }).join('') + ((state.memoryQuery?state.memorySearchCursor:state.memoryCursor)?'<button onclick="cosApp.loadMoreMemories()">Load more memories</button>':'') + (!state.memoryPageCapable?'<p class="muted">Bounded browsing on this server version.</p>':'');
636
+ if(hadFocus) {var replacement=inbox.querySelector('input[aria-label="Search memories"]'); replacement.focus(); try{replacement.setSelectionRange(selection[0],selection[1]);}catch(ignore){} }
637
+ if (!rows.length) { detail.innerHTML = '<div class="empty"><div><h2>' + (state.memoryQuery ? 'No memories match that lookup.' : 'No memories yet.') + '</h2><p>Drop markdown into memory/, or configure a bridge for the vector store.</p></div></div>'; return; }
638
+ var m = rows.find(function (x) { return x.id === state.selectedMemory; });
639
+ ensureMemoryDetail(m.id);
640
+ var full = state.memoryDetail[m.id] || {};
641
+ var record = full._error ? m : Object.assign({}, m, full);
642
+ detail.innerHTML = '<div class="context-actions row spread"><span class="eyebrow">MEMORY</span><div class="row">' + (record.filePath ? '<button class="quiet" onclick="cosApp.reveal(' + attr(m.id) + ')">Reveal in Finder</button>' : '') + '<button onclick="cosApp.copyMemory(' + attr(m.id) + ')">Copy context</button></div></div>' +
643
+ '<span class="badge green">Saved memory</span><h2 style="margin-top:12px">' + esc(record.summary || 'Memory') + '</h2><p class="intro">' + esc([record.type, dateOnly(record.created_at), record.source].filter(Boolean).join(' · ')) + '</p>' +
644
+ (full._error ? '<div class="notice">' + esc(full._error) + '</div>' : '') +
645
+ '<div class="timeline">' + event('Captured memory', esc(record.type || 'Memory'), '<div class="quote">' + esc(record.content || record.summary || '') + '</div>') + event('Available for recall', record.filePath ? 'Plain files' : 'Memory store', '<p>Active memories are available to authorized sessions. Later retrieval, use, or result appears under Recent learning when it is recorded.</p>', false, true) + '</div>';
646
+ }
647
+ var memorySearchTimer = null;
648
+ function memoryQuery(q) {
649
+ state.memoryQuery=q;clearTimeout(memorySearchTimer);
650
+ if(q.trim().length===1)return;
651
+ memorySearchTimer=setTimeout(function(){loadMemoryPage(false);},q?350:0);
652
+ }
653
+
654
+ // ── knowledge ───────────────────────────────────────────────────
655
+ function knowledgeAside() {
656
+ var focus = state.knowledgeFocus && findEvent(state.knowledgeFocus);
657
+ return '<div class="knowledge-label">EXPLORE KNOWLEDGE</div>' +
658
+ '<button class="knowledge-route ' + (state.knowledgeTab === 'graph' ? 'active' : '') + '" aria-pressed="' + (state.knowledgeTab === 'graph') + '" onclick="cosApp.setKnowledgeTab(\'graph\')"><span>Ask the graph</span><small>Explore connections · ' + (state.graphStatus&&state.graphStatus.engine==='explicit_document_links'?'Document links':'LightRAG') + '</small></button>' +
659
+ '<button class="knowledge-route ' + (state.knowledgeTab === 'sources' ? 'active' : '') + '" aria-pressed="' + (state.knowledgeTab === 'sources') + '" onclick="cosApp.setKnowledgeTab(\'sources\')"><span>Source records</span><small>What was actually said</small></button>' +
660
+ '<button class="knowledge-route ' + (state.knowledgeTab === 'setup' ? 'active' : '') + '" aria-pressed="' + (state.knowledgeTab === 'setup') + '" onclick="cosApp.setKnowledgeTab(\'setup\')"><span>Set up Knowledge</span><small>Sources, owner, first index</small></button>' +
661
+ '<div class="knowledge-note"><h3>' + (focus ? 'Related to: ' + esc(focus.title) : 'Better context. Traceable learning.') + '</h3><p>Sources explain a memory. Relationships add context. Run evidence shows whether a change helped.</p><button class="link" onclick="cosApp.backToLearning()">← Back to recent learning</button></div>';
662
+ }
663
+ function hostLabel(h) { return String(h || '').replace(/\.local$/, '').replace(/-/g, ' '); }
664
+ function fileGraph() { return !!(state.graphStatus && state.graphStatus.engine === 'explicit_document_links'); }
665
+ function syncCard() {
666
+ var g = state.graphStatus, s = state.status || {};
667
+ if (state.errors.graph) return '<section class="source-card sync-card"><div class="row spread"><h3>Sync</h3></div><p class="owner">' + esc(state.errors.graph) + '</p><div class="actions"><button onclick="cosApp.refreshGraph()">Retry</button></div></section>';
668
+ if (!g) return '<section class="source-card sync-card"><div class="row spread"><h3>Sync</h3></div><p class="owner">Loading…</p></section>';
669
+ if (fileGraph()) return '<section class="source-card sync-card"><h3>Document links</h3><p>Granted documents and your assertions build this local graph. Links describe associations; they do not establish causation.</p><dl class="sync-grid"><dt>Indexed</dt><dd>' + fmt(g.entities || 0) + ' points · ' + fmt(g.relationships || 0) + ' links</dd><dt>Index</dt><dd>' + esc(g.index_state || 'unknown') + '</dd><dt>Visible</dt><dd data-role="graph-visible">' + (state.graphVisible == null ? 'Open the graph to load points' : fmt(state.graphVisible) + ' points') + '</dd></dl><p>Refresh granted documents in Memory stewardship to update their evidence and links.</p><button onclick="cosApp.openStewardship()">Current sources and rules</button></section>';
670
+ var src = g.source || {}, q = g.queue || {}, b = g.budget || {}, lock = g.lock || {}, proc = g.processor || {};
671
+ var ownerName = hostLabel(src.owner_host) || 'the owner Mac';
672
+ var owner = (src.owner_state === 'owner' ? 'Owner: <b>this Mac</b> (' + esc(ownerName) + ') · canonical graph' : src.owner_state === 'replica' ? 'Owner: <b>' + esc(ownerName) + '</b> · this Mac reads an iCloud replica' : 'No ingestion owner set. Run --set-owner on the Mac that processes the queue.');
673
+ var processor = (proc.state === 'none' || !proc.state ? 'No scheduled processor on this Mac' : proc.state === 'installed-idle' ? 'Processor installed, no run recorded yet' : 'Processor ' + esc(proc.state));
674
+ // Index now (server 6.44.8): one bounded run of the queue, started here on the
675
+ // owner Mac. A held lock (a Claude session, a scheduled run, a backup) shows
676
+ // as "Indexing now" and the card refreshes until it is free again.
677
+ var lockHeld = lock.state === 'exclusive' || lock.state === 'shared';
678
+ var pendingN = q.pending != null ? Number(q.pending) : 0;
679
+ {
680
+ if (state.ingesting && state.ingesting.note) processor += ' · <span class="muted">' + esc(state.ingesting.note) + '</span>';
681
+ else if (lockHeld) processor += ' · <span class="muted">Indexing now' + (lock.owner_pid ? ' (pid ' + esc(lock.owner_pid) + ')' : '') + '</span>';
682
+ else if (src.owner_state === 'owner' && pendingN > 0) {
683
+ var sizes = [5, 10, 25, 50].filter(function (n) { return n < pendingN; });
684
+ var options = sizes.map(function (n) { return '<option value="' + n + '"' + (n === state.ingestLimit ? ' selected' : '') + '>' + n + '</option>'; }).join('');
685
+ if (pendingN <= 50) options += '<option value="' + pendingN + '"' + (sizes.indexOf(state.ingestLimit) === -1 ? ' selected' : '') + '>' + fmt(pendingN) + ' (all)</option>';
686
+ processor += ' <span class="ingest-control"><span class="ingest-pending">' + fmt(pendingN) + ' pending</span><label for="ingestLimit">Batch size</label><select id="ingestLimit" aria-label="Index batch size">' + options + '</select><button type="button" class="hairline" onclick="cosApp.startIngest()">Index now</button></span>';
687
+ }
688
+ }
689
+ var queued = (q.pending != null ? fmt(q.pending) + ' pending' : 'unknown') + (q.oldest_pending_at ? ' · oldest ' + esc(dateOnly(q.oldest_pending_at)) : '') + (q.missing_sources != null ? ' · ' + fmt(q.missing_sources) + ' missing sources' : '') + (q.conflict_copies != null ? ' · ' + fmt(q.conflict_copies) + ' conflict copies' : '');
690
+ var indexed = (g.entities != null ? fmt(g.entities) + ' entities' : 'no graph') + (g.relationships != null ? ' · ' + fmt(g.relationships) + ' relationships' : '') + (g.source_updated_at ? ' · graph updated ' + esc(stamp(g.source_updated_at)) : '');
691
+ var invites = g.index_state === 'missing' || g.index_state === 'stale';
692
+ var build = state.building ? ' <span class="muted">' + esc(state.building) + '</span>' : (invites ? ' <button class="quiet" onclick="cosApp.buildIndex()">Build index (usually a few seconds)</button>' : '');
693
+ var captured = s.meetingLibraryCount != null ? fmt(s.meetingLibraryCount) + ' meetings in the library' : 'unknown';
694
+ var visible = state.graphController && state.graphVisible != null ? fmt(state.graphVisible) + ' entities in this view' : (state.knowledgeTab === 'graph' ? 'loading the neighborhood' : 'open the graph to load a neighborhood');
695
+ return '<section class="source-card sync-card"><div class="row spread"><h3>Sync</h3></div>' +
696
+ '<p class="owner">' + owner + '</p>' +
697
+ '<dl class="sync-grid"><dt>Captured</dt><dd>' + esc(captured) + '</dd><dt>Queued</dt><dd>' + queued + '</dd><dt>Indexing</dt><dd>' + processor + '</dd><dt>Indexed</dt><dd>' + indexed + '</dd><dt>Visible</dt><dd data-role="graph-visible">' + esc(visible) + '</dd><dt>Index</dt><dd>' + esc(g.index_state || 'unknown') + (g.index_built_at ? ' · built ' + esc(stamp(g.index_built_at)) : '') + (g.index_degraded ? ' · degraded' : '') + build + '</dd><dt>Budget</dt><dd>' + (b.used != null && b.cap != null ? fmt(b.used) + ' of ' + fmt(b.cap) + ' calls today' : 'unknown') + ' · lock ' + esc(lock.state || 'unknown') + (lock.owner_pid ? ' (pid ' + esc(lock.owner_pid) + ', advisory)' : '') + '</dd></dl>' +
698
+ progressBlock() + '<p class="small">' + 'Values from this Mac.' + ' Saved investigations and assertions show their operation receipts in the workspace.</p></section>';
699
+ }
700
+ // ── Set up Knowledge (server 6.44.9): seven steps, each a live check with its own control ──
701
+ function loadSetup() {
702
+ if (!state.graphStatus && !state.errors.graph) return loadGraphStatus().then(function(){ return loadSetup(); });
703
+ if (!state.graphStatus) { state.setupLoading=false; state.setupError=state.errors.graph || 'Graph capability is unavailable. Refresh the graph status before setup.'; return Promise.resolve(null); }
704
+ if (fileGraph()) { state.setupLoading=false; state.setupError=null; state.setup={engine:'explicit_document_links'}; if(state.view==='knowledge')render(); return Promise.resolve(state.setup); }
705
+ state.setupLoading = true; state.setupError = null; if (state.view === 'knowledge') render();
706
+ return call('graph.setup').then(function (d) { state.setup = d; state.setupLoading = false; render(); }, function (e) { state.setupError = e.message; state.setupLoading = false; render(); });
707
+ }
708
+ function setupAction(name, op, args, onDone) {
709
+ state.setupBusy = name; state.armed = null; render();
710
+ return call(op, args).then(function (d) { state.setupBusy = null; if (onDone) onDone(d); return loadSetup(); }, function (e) { state.setupBusy = null; toast(e.message); render(); });
711
+ }
712
+ function setupStep(n, done, title, body, cls) {
713
+ return '<div class="setup-step ' + (done ? 'done' : (cls || 'todo')) + '"><div class="setup-mark">' + (done ? '✓' : n) + '</div><div class="setup-body"><h4>' + title + '</h4>' + body + '</div></div>';
714
+ }
715
+ function shortPath(p) { return String(p || '').replace(/^\/Users\/[^/]+/, '~'); }
716
+ function attr(v) { return JSON.stringify(String(v)).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
717
+ // The two pickers of step 4. A card is a radio: choosing posts the op and refreshes the checklist.
718
+ function extractionCards(ext, busy) {
719
+ var tiers = ext.tiers || [];
720
+ if (!tiers.length) return '<p class="muted">Indexing tiers need server 6.44.11.</p>';
721
+ return '<div class="pick-grid">' + tiers.map(function (tr) {
722
+ return '<label class="pick-card ' + (tr.selected ? 'on' : '') + '"><input type="radio" name="extractionTier" ' + (tr.selected ? 'checked' : '') + (busy ? ' disabled' : '') + ' onchange="cosApp.chooseExtraction(' + attr(tr.id) + ')"><div><b>' + esc(tr.label) + '</b><p>' + esc(tr.detail) + '</p></div></label>';
723
+ }).join('') + '</div>';
724
+ }
725
+ // The down-select (0.5.200, server 6.44.12). One question the user answers, may
726
+ // text leave this Mac; one fact detected, an OpenAI key; Ollama running picks
727
+ // premium over light. The answer moves the Recommended mark, never the choice.
728
+ function localOnlyStrip(emb, busy) {
729
+ if (!emb.preference) return '<p class="muted">The recommendation needs server 6.44.12.</p>';
730
+ var v = emb.preference.local_only, rec = emb.recommended || null;
731
+ function btn(value, text) { return '<button class="quiet' + (v === value ? ' on' : '') + '" ' + (busy ? 'disabled' : '') + ' onclick="cosApp.answerLocalOnly(' + value + ')">' + text + '</button>'; }
732
+ return '<div class="setup-row ask-strip"><span>May your text leave this Mac for embeddings?</span>' + btn(false, 'Cloud is fine') + btn(true, 'Stays on this Mac') + '</div>' +
733
+ (rec ? '<p class="pick-note"><b>Recommended: ' + esc(rec.label) + '.</b> ' + esc(rec.reason) + '</p>' : '');
734
+ }
735
+ function embeddingCards(emb, busy) {
736
+ var rows = emb.providers || [];
737
+ if (!rows.length) return '<p class="muted">Embedding choices need server 6.44.11.</p>';
738
+ var fetch = emb.fetch || null;
739
+ var recId = emb.recommended ? emb.recommended.id : null;
740
+ var cards = rows.map(function (r) {
741
+ var canPick = !emb.locked || r.selected;
742
+ var fetching = fetch && fetch.state === 'running' && fetch.provider === r.id;
743
+ var fetchFailed = fetch && fetch.state === 'failed' && fetch.provider === r.id;
744
+ var offline = r.id === 'ollama' && /not running/.test(r.detail || '');
745
+ var fetchBtn = r.kind === 'local' && !r.ready && !offline ? '<button class="quiet" ' + (busy || fetching ? 'disabled' : '') + ' onclick="cosApp.fetchEmbedding(' + attr(r.id) + ')">' + (fetching ? 'Fetching…' : 'Fetch model') + '</button>' : '';
746
+ var badge = r.id === recId ? '<span class="pick-badge">Recommended</span>' : '';
747
+ var fixLine = r.selected && !r.ready && r.fix ? '<p class="bad">' + esc(r.fix) + '</p>' : '';
748
+ return '<label class="pick-card ' + (r.selected ? 'on' : '') + (canPick ? '' : ' off') + (r.id === recId ? ' rec' : '') + '"><input type="radio" name="embeddingProvider" ' + (r.selected ? 'checked' : '') + (canPick && !busy ? '' : ' disabled') + ' onchange="cosApp.chooseEmbedding(' + attr(r.id) + ')"><div><b>' + esc(r.label) + '</b> <span class="muted">' + esc(r.model) + (r.dimensions != null ? ' · ' + fmt(r.dimensions) + ' dims' : '') + '</span>' + badge + '<p>' + esc(r.cost) + '</p><p class="' + (r.ready ? 'ok' : '') + '">' + (r.ready ? '✓ ' : '') + esc(r.detail) + '</p>' + fixLine + (fetching ? '<p class="muted">Fetching ' + esc(fetch.model) + '… this page refreshes as it runs.</p>' : fetchFailed ? '<p class="bad">Fetch failed: ' + esc(fetch.error || 'unknown') + '</p>' : '') + fetchBtn + '</div></label>';
749
+ }).join('');
750
+ var note = '';
751
+ if (emb.mismatch && emb.manifest) note = '<p class="pick-note bad">This graph was built with ' + esc(emb.manifest.provider) + ' (' + esc(emb.manifest.model) + ', ' + fmt(emb.manifest.dimensions) + ' dims) but ' + esc(emb.provider) + ' is chosen. Choose the one it was built with; Index now is blocked until then.</p>';
752
+ else if (emb.locked) note = '<p class="pick-note">This graph was built with ' + esc(emb.label || emb.provider) + '. Changing the embedding means rebuilding the graph, and that path is not built yet.</p>';
753
+ else if (emb.ready === false) note = '<p class="pick-note bad">' + esc(emb.label || emb.provider) + ' is chosen but not ready on this Mac. ' + esc(emb.fix || '') + ' Indexing is blocked until then.</p>';
754
+ return '<div class="pick-grid">' + cards + '</div>' + note;
755
+ }
756
+ function watchFetch() {
757
+ if (state.fetchWatch) return;
758
+ var ticks = 0;
759
+ state.fetchWatch = setInterval(function () {
760
+ ticks++;
761
+ loadSetup().then(function () {
762
+ var f = state.setup && state.setup.embedding && state.setup.embedding.fetch;
763
+ if (!f || f.state !== 'running' || ticks > 360) { clearInterval(state.fetchWatch); state.fetchWatch = null; if (f && f.state === 'done') toast('Model fetched.'); else if (f && f.state === 'failed') toast('Fetch failed: ' + (f.error || 'unknown')); }
764
+ });
765
+ }, 10000);
766
+ }
767
+ function setupDetail() {
768
+ if (fileGraph()) return '<section class="source-card setup-card"><h3>Set up document links</h3><p>This runtime indexes Markdown and text documents explicitly granted by the instance owner. Use the installed runtime’s <code>manage.py grant</code> command to add a document. Write links as <code>[[Document title]]</code> to connect ideas.</p><p>Open Current sources to inspect or refresh granted documents. In the graph, select two points, add waypoints and find paths up to seven hops. Save a hypothesis separately from your asserted relationship.</p><p>Model extraction and embeddings are not configured in this file runtime. Keyword search and explicit document links are available.</p><button onclick="cosApp.openStewardship()">Current sources and rules</button></section>';
769
+ var s = state.setup;
770
+ if (state.setupError) return '<section class="source-card setup-card"><h3>Set up Knowledge</h3><p class="owner">' + esc(state.setupError) + '</p><div class="actions"><button onclick="cosApp.setupRefresh()">Retry</button></div></section>';
771
+ if (!s) return '<section class="source-card setup-card"><h3>Set up Knowledge</h3><p class="owner">Checking this Mac…</p></section>';
772
+ var checks = s.checks || [], checksOk = checks.length > 0 && checks.every(function (c) { return c.ok; });
773
+ var sources = s.sources || [], enabledWithFiles = sources.filter(function (r) { return r.enabled && r.exists && (r.files || 0) > 0; });
774
+ var owner = s.owner || {}, isOwner = owner.is_owner === true, budget = s.budget || {}, sample = s.sample || {}, cands = sample.candidates || [];
775
+ var graphN = (s.graph || {}).entities, hasGraph = graphN != null && graphN > 0, schedule = s.schedule || {}, lk = s.lock || {};
776
+ var lockHeld = lk.state === 'exclusive' || lk.state === 'shared';
777
+ var busy = state.setupBusy;
778
+ var steps = [];
779
+ steps.push(setupStep(1, checksOk, 'Enable Knowledge',
780
+ '<p>What indexing needs on this Mac.</p>' + checks.map(function (c) { return '<div class="setup-source"><span class="' + (c.ok ? 'ok' : 'bad') + '">' + (c.ok ? '✓' : '!') + '</span><span>' + esc(c.detail) + '</span></div>'; }).join(''),
781
+ checks.length && !checksOk ? 'blocked' : 'todo'));
782
+ var sourceRows = sources.map(function (r) {
783
+ return '<div class="setup-source"><code>' + esc(shortPath(r.path)) + '</code><span class="muted">' + (r.exists ? (r.files != null ? fmt(r.files) + ' document' + (r.files === 1 ? '' : 's') : '') : 'missing') + '</span>' +
784
+ '<button class="quiet" ' + (busy ? 'disabled' : '') + ' onclick="cosApp.sourceToggle(' + attr(r.path) + ', ' + (!r.enabled) + ')">' + (r.enabled ? 'On' : 'Off') + '</button>' +
785
+ '<button class="quiet" ' + (busy ? 'disabled' : '') + ' onclick="cosApp.sourceRemove(' + attr(r.path) + ')" aria-label="Remove this folder">×</button></div>';
786
+ }).join('');
787
+ steps.push(setupStep(2, enabledWithFiles.length > 0, 'Choose sources',
788
+ '<p>Folders of notes or documents (Markdown and text). Only files in these folders are read.</p>' + sourceRows +
789
+ '<div class="setup-row"><button ' + (busy ? 'disabled' : '') + ' onclick="cosApp.pickFolder()">' + (busy === 'add' ? 'Adding…' : 'Add a folder…') + '</button>' + (sources.length && !enabledWithFiles.length ? '<span class="muted">No documents found in the folders that are on.</span>' : '') + '</div>'));
790
+ var ownerBody = isOwner ? '<p>This Mac (' + esc(hostLabel(owner.this_host)) + ') indexes the queue. Its graph, queue and sources stay here.</p>'
791
+ : owner.owner_host ? '<p><b>' + esc(hostLabel(owner.owner_host)) + '</b> indexes the queue; this Mac reads a replica. Make this Mac the owner only if that Mac stops indexing.</p>'
792
+ : '<p>No Mac is set to index yet.</p>';
793
+ if (!isOwner) ownerBody += '<div class="setup-row"><button ' + (busy ? 'disabled' : '') + ' onclick="cosApp.claimOwner()">' + (state.armed === 'owner' ? 'Click again to make this Mac the owner' : busy === 'owner' ? 'Setting…' : 'Make this Mac the owner') + '</button></div>';
794
+ steps.push(setupStep(3, isOwner, 'Owner Mac', ownerBody));
795
+ var budgetOk = budget.used != null && budget.cap != null && budget.used < budget.cap;
796
+ var embBlock = s.embedding || {}, extBlock = s.extraction || {};
797
+ var localEmb = embBlock.kind === 'local';
798
+ var embNotReady = embBlock.ready === false;
799
+ steps.push(setupStep(4, checksOk && budgetOk && !embBlock.mismatch && !embNotReady, 'Choose how it indexes',
800
+ '<p>Extraction quality intent for the next run. Effective execution: ' + esc(extBlock.execution ? [extBlock.execution.provider,extBlock.execution.requested_model,extBlock.execution.reasoning_effort].filter(Boolean).join(' · ') : 'unavailable in this version') + '. Actual model identity: ' + esc((extBlock.execution || {}).actual_identity || 'unverified') + '.</p>' + extractionCards(extBlock, busy) +
801
+ '<p style="margin-top:12px">Embeddings for search, one choice for the knowledge graph and every meeting index. A graph keeps the embedding it was built with.</p>' + localOnlyStrip(embBlock, busy) + embeddingCards(embBlock, busy) +
802
+ '<div class="setup-source" style="margin-top:10px"><span>Budget today: ' + (budget.used != null ? fmt(budget.used) + ' of ' + fmt(budget.cap) + ' calls' : 'unknown') + (budgetOk ? '' : ' (used up until tomorrow)') + '</span></div>' +
803
+ '<p>What leaves this Mac: each document\'s text goes to the model backend for entity extraction' + (localEmb ? '; embeddings are computed on this Mac' : ' and to OpenAI for embeddings') + '. The graph, the queue and this list of folders never leave it.</p>',
804
+ embBlock.mismatch || embNotReady ? 'blocked' : 'todo'));
805
+ var sampleDone = (sample.indexed || 0) >= 1 || hasGraph;
806
+ var sampleBody = sampleDone ? '<p>' + ((sample.indexed || 0) >= 1 ? fmt(sample.indexed) + ' sample document' + (sample.indexed === 1 ? '' : 's') + ' indexed.' : 'Your graph already holds ' + fmt(graphN) + ' entities.') + '</p>' : '<p>The three newest documents from the folders that are on, through the same checks a meeting gets, in one bounded run.</p>';
807
+ if (cands.length) sampleBody += cands.map(function (c) { return '<div class="setup-source"><code>' + esc(String(c.path).split('/').pop()) + '</code><span class="muted">' + (c.bytes != null ? fmt(Math.max(1, Math.round(c.bytes / 1024))) + ' KB' : '') + '</span></div>'; }).join('');
808
+ if (state.ingesting && state.ingesting.note) sampleBody += '<p class="muted">' + esc(state.ingesting.note) + '</p>';
809
+ else if (lockHeld) sampleBody += progressBlock();
810
+ if ((sample.queued || 0) > 0 && !lockHeld) sampleBody += '<p class="muted">' + fmt(sample.queued) + ' queued and waiting for a run.</p>';
811
+ if (cands.length && isOwner && !lockHeld) sampleBody += '<div class="setup-row"><button ' + (busy || !checksOk || embNotReady ? 'disabled' : '') + ' onclick="cosApp.indexSample()">' + (busy === 'sample' ? 'Queueing…' : 'Index ' + (cands.length === 1 ? 'this document' : 'these ' + cands.length)) + '</button>' +
812
+ (embNotReady ? '<span class="muted">Blocked: ' + esc(embBlock.label || embBlock.provider) + ' is not ready. ' + esc(embBlock.fix || '') + '</span>' : '') + '</div>';
813
+ else if (cands.length && !isOwner) sampleBody += '<p class="muted">Indexing runs on the owner Mac.</p>';
814
+ steps.push(setupStep(5, sampleDone, 'Index three sample documents', sampleBody, checksOk ? 'todo' : 'blocked'));
815
+ var askBody = '<p>One question, answered from the graph. About a minute; two model calls under today\'s budget.</p>' +
816
+ '<div class="setup-row"><input id="askQ" placeholder="' + (hasGraph ? 'Who do I work with most?' : 'Index something first') + '" value="' + esc(state.askQ) + '" ' + (s.ask_ready && !state.askBusy ? '' : 'disabled') + ' onkeydown="if(event.key===\'Enter\')cosApp.askGraph()"><button ' + (s.ask_ready && !state.askBusy ? '' : 'disabled') + ' onclick="cosApp.askGraph()">' + (state.askBusy ? '<span class="ask-spin" aria-hidden="true"></span>Asking…' : 'Ask') + '</button></div>' +
817
+ (state.askBusy ? askProgressHtml(state.askStartedAt) : '') +
818
+ (state.askAnswer ? '<div class="setup-answer">' + esc(state.askAnswer.answer) + '</div><p class="muted">' + (state.askAnswer.elapsed_s != null ? Math.round(state.askAnswer.elapsed_s) + ' s, ' : '') + esc(state.askAnswer.mode || 'hybrid') + ' mode</p>' : '');
819
+ steps.push(setupStep(6, !!state.askAnswer, 'Ask one question', askBody, s.ask_ready ? 'todo' : 'blocked'));
820
+ var scheduleBody = schedule.installed ? '<p>On: a batch of up to 4 queued documents every ' + (schedule.interval_s ? fmt(Math.round(schedule.interval_s / 60)) + ' minutes' : 'interval') + ', logged under ~/Library/Logs/COS.</p>' : '<p>Off. Turn it on and this Mac indexes what is queued on a schedule, in bounded batches of 4, under the daily budget.</p>';
821
+ if (isOwner) scheduleBody += schedule.installed
822
+ ? '<div class="setup-row"><button class="quiet" ' + (busy ? 'disabled' : '') + ' onclick="cosApp.scheduleSet(false)">' + (busy === 'schedule' ? 'Working…' : 'Turn off') + '</button></div>'
823
+ : '<div class="setup-row"><select id="scheduleInterval"><option value="900">every 15 minutes</option><option value="3600" selected>every hour</option><option value="14400">every 4 hours</option><option value="86400">once a day</option></select><button ' + (busy ? 'disabled' : '') + ' onclick="cosApp.scheduleSet(true)">' + (busy === 'schedule' ? 'Working…' : 'Turn on') + '</button></div>';
824
+ else scheduleBody += '<p class="muted">Schedules live on the owner Mac.</p>';
825
+ steps.push(setupStep(7, schedule.installed === true, 'Scheduled batches', scheduleBody));
826
+ var done = [checksOk, enabledWithFiles.length > 0, isOwner, checksOk && budgetOk, sampleDone, !!state.askAnswer, schedule.installed === true].filter(Boolean).length;
827
+ return '<section class="source-card setup-card"><div class="row spread"><h3>Set up Knowledge</h3><span class="setup-progress">' + done + ' OF 7 DONE' + (state.setupLoading ? ' · REFRESHING' : '') + '</span></div>' +
828
+ '<p class="owner">From zero to a first index on this Mac. Each step is a live check; do them in order.</p><div class="setup-steps">' + steps.join('') + '</div>' +
829
+ '<div class="actions"><button class="quiet" onclick="cosApp.setupRefresh()">Refresh checks</button><button type="button" class="hairline" data-role="real-records" onclick="cosApp.openSourceRecords()">Real records</button></div></section>';
830
+ }
831
+ function renderKnowledge() {
832
+ document.querySelector('#workspace').style.gridTemplateColumns = ''; document.querySelector('#inbox').classList.remove('hidden');
833
+ document.querySelector('#inbox').innerHTML = knowledgeAside();
834
+ document.querySelector('#detail').innerHTML = state.knowledgeTab === 'graph' ? graphDetail() + '<details class="knowledge-health"><summary>Knowledge status and maintenance</summary>' + syncCard() + '<div id="graphDups">' + duplicatesCard() + '</div></details>' : syncCard() + (state.knowledgeTab === 'setup' ? setupDetail() : knowledgeSources());
835
+ if (state.knowledgeTab === 'graph') mountKnowledgeGraph();
836
+ if (state.knowledgeTab === 'setup' && !state.setup && !state.setupLoading && !state.setupError) loadSetup();
837
+ }
838
+ function knowledgeSources() {
839
+ var focus = state.knowledgeFocus && findEvent(state.knowledgeFocus);
840
+ var cards = [];
841
+ if (focus) {
842
+ var full = state.detail[focus.event_id]; if (!full) ensureDetail(focus.event_id);
843
+ var e = Object.assign({}, focus, full && !full._error ? full : {});
844
+ (e.source_refs || []).forEach(function (r, i) { cards.push({ title: STORE[r.kind] || r.kind || 'Source', kind: r.kind || '', id: r.id || '', text: r.excerpt || '', note: i === 0 ? 'The record this lesson was projected from.' : '' }); });
845
+ var term = exploreTerm(e);
846
+ if (!state.passages[term] && !state.loading['p:' + term]) { state.loading['p:' + term] = true; call('graph.passages', { entity: term, limit: 5 }).then(function (d) { state.passages[term] = d; delete state.loading['p:' + term]; render(); }, function (er) { state.passages[term] = { _error: er.message }; delete state.loading['p:' + term]; render(); }); }
847
+ var p = state.passages[term];
848
+ if (p && !p._error) (p.items || []).forEach(function (it) { var s = it.source || {}; cards.push({ title: s.title || 'Passage', kind: s.status === 'resolved' ? 'Meeting excerpt' : 'Passage (' + (s.status || 'unresolved') + ')', id: it.chunk_id, text: it.excerpt, note: s.date ? s.date : '' }); });
849
+ else if (p && p._error) cards.push({ title: 'Passages', kind: 'unavailable', id: '', text: p._error, note: '' });
850
+ } else {
851
+ cards = state.recent.slice(0, 8).filter(function (e) { return (e.source_refs || []).length; }).map(function (e) { var r = e.source_refs[0]; return { title: e.title, kind: STORE[r.kind] || r.kind || 'Source', id: e.event_id, text: r.excerpt || '', note: stamp(e.ts), event: e.event_id }; });
852
+ }
853
+ return '<div class="row spread" id="sourceRecords"><span class="eyebrow">MEMORIES / KNOWLEDGE / SOURCES</span>' + (cards.length ? '<button type="button" class="hairline" data-role="real-records" onclick="cosApp.openSourceRecords()">Real records</button>' : '<span class="badge">No records</span>') + '</div><h2 style="margin-top:12px">The context behind the lesson.</h2><p class="intro">Open the original evidence before reusing an interpretation.</p>' +
854
+ (cards.length ? cards.map(function (x) { return '<article class="source-card"><div class="row spread"><h3>' + esc(x.title) + '</h3><span class="badge">' + esc(x.kind) + '</span></div>' + (x.text ? '<div class="quote">' + esc(x.text) + '</div>' : '') + (x.note ? '<p>' + esc(x.note) + '</p>' : '') + (x.event ? '<div class="actions"><button class="link" onclick="cosApp.select(' + attr(x.event) + ');cosApp.setFilter(\'recent\')">Inspect learning →</button></div>' : '') + '</article>'; }).join('') : '<div class="empty"><div><h3>No source records yet.</h3><p>Your first saved lesson will bring its source here.</p></div></div>');
855
+ }
856
+ function graphDetail() {
857
+ return '<div id="graphAsk">' + askBlockInner() + '</div><section class="knowledge-visual"><div class="row spread actual-header"><h2>Your knowledge</h2><span class="badge green">' + (fileGraph() ? 'Your document links' : 'Your LightRAG data') + '</span></div><p class="intro">Explore connected people and ideas. Ask a question above to trace the connections that matter.</p><div id="graphMount" class="cgx-host"></div></section>';
858
+ }
859
+ // Ask the graph in plain language from the focus (Miles 2026-09-07: "Show me the
860
+ // relation between Queen and Ukaoma"). The question runs the same hybrid query
861
+ // the setup step uses; the answer renders here, with the names it mentions as
862
+ // focus buttons. About a minute; two model calls under the subscription.
863
+ // A safe Markdown subset for a model answer: escape first, then headings,
864
+ // bold, inline code, bullet lists, pipe tables and paragraphs. Nothing else.
865
+ function renderMarkdown(md) {
866
+ var lines = esc(md || '').split('\n'), out = [], list = null, table = null;
867
+ function inline(s) { return s.replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/\[(S?\d+)\]/g, function (_, n) { var id = n.charAt(0) === 'S' ? n : 'S' + n; return '<a class="ref" href="#ask-ref-' + id + '">[' + n + ']</a>'; }); }
868
+ function flush() { if (list) { out.push('<ul>' + list.join('') + '</ul>'); list = null; } if (table) { out.push('<table>' + table.join('') + '</table>'); table = null; } }
869
+ lines.forEach(function (raw) {
870
+ var line = raw.replace(/\s+$/, '');
871
+ if (!line.trim()) { flush(); return; }
872
+ var h = line.match(/^(#{1,4})\s+(.*)$/);
873
+ if (h) { flush(); out.push('<h' + Math.min(h[1].length + 3, 6) + '>' + inline(h[2]) + '</h' + Math.min(h[1].length + 3, 6) + '>'); return; }
874
+ var li = line.match(/^\s*[-*•]\s+(.*)$/);
875
+ if (li) { if (table) flush(); list = list || []; list.push('<li>' + inline(li[1]) + '</li>'); return; }
876
+ if (/^\s*\|.*\|\s*$/.test(line)) {
877
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) return;
878
+ if (list) flush();
879
+ var cells = line.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return inline(c.trim()); });
880
+ var tag = table ? 'td' : 'th'; table = table || [];
881
+ table.push('<tr>' + cells.map(function (c) { return '<' + tag + '>' + c + '</' + tag + '>'; }).join('') + '</tr>'); return;
882
+ }
883
+ flush(); out.push('<p>' + inline(line) + '</p>');
884
+ });
885
+ flush();
886
+ return out.join('');
887
+ }
888
+ function parseAskAnswer(answer) {
889
+ var text = String(answer || '');
890
+ var marker = '\n\nEvidence references:\n';
891
+ var i = text.indexOf(marker);
892
+ var prose = i < 0 ? text : text.slice(0, i);
893
+ var refs = [];
894
+ if (i >= 0) {
895
+ text.slice(i + marker.length).split('\n').forEach(function (line) {
896
+ var m = line.match(/^\[(S\d+)\]\s+(.*)$/);
897
+ if (!m) return;
898
+ var parts = m[2].split(' · ');
899
+ refs.push({ id: m[1], source: (parts[0] || '').trim() || 'Original passage', date: restTrim(parts[1], 'date unknown'), location: parts[2] || '', status: parts[3] || '' });
900
+ });
901
+ }
902
+ return { prose: prose, refs: refs };
903
+ }
904
+ function restTrim(value, empty) {
905
+ var s = String(value || '').trim();
906
+ return !s || s === empty ? '' : s;
907
+ }
908
+ function askRefsHtml(refs) {
909
+ if (!refs.length) return '';
910
+ return '<div class="ask-refs"><h4>Sources</h4><ol>' + refs.map(function (r) {
911
+ var note = r.status === 'source_unresolved' ? 'not linked to a meeting file' : '';
912
+ var meta = [r.date, note].filter(Boolean).join(' · ');
913
+ return '<li id="ask-ref-' + esc(r.id) + '"><span class="ref">[' + esc(r.id) + ']</span> ' + esc(r.source) + (meta ? '<span class="muted"> · ' + esc(meta) + '</span>' : '') + '</li>';
914
+ }).join('') + '</ol></div>';
915
+ }
916
+ // The entities an answer names, as the same card the inspector shows: headings
917
+ // and bold names are looked up in the index; an exact id wins, then a person.
918
+ function askEntityCandidates(answer, q) {
919
+ var names = [];
920
+ function add(n) { n = String(n || '').replace(/\s*\(.*?\)\s*$/, '').replace(/[*_#`]/g, '').trim(); if (n.length >= 3 && n.length <= 60 && /[A-Za-z]/.test(n) && names.indexOf(n) === -1) names.push(n); }
921
+ (answer.match(/^#{1,4}\s+(.+)$/gm) || []).forEach(function (h) { add(h.replace(/^#+\s+/, '').replace(/^(Relationship|Relationships|References|Key Context|Summary|Overview)\s*:?\s*/i, '')); });
922
+ (answer.match(/\*\*([^*]{3,60})\*\*/g) || []).forEach(function (b) { var n = b.replace(/\*\*/g, ''); if (/^[A-Z][\w'.-]*(\s+[A-Z][\w'.-]*){0,3}$/.test(n)) add(n); });
923
+ var m = q.match(/between\s+(.+?)\s+and\s+(.+?)\??$/i); if (m) { add(m[1]); add(m[2]); }
924
+ return names.filter(function (n) { return !/^(Primary Relationship|Role|Current Focus|Completion|Birthday|Children|Attribute|Detail|Regular syncs|Family management|Shared parenthood)$/i.test(n); }).slice(0, 8);
925
+ }
926
+ function loadAskCards(answer, q, serial) {
927
+ var names = askEntityCandidates(answer, q);
928
+ state.graphAsk.cards = []; state.graphAsk.cardsBusy = names.length > 0;
929
+ var host = document.querySelector('#graphAsk'); if (host) host.innerHTML = askBlockInner();
930
+ var seen = {};
931
+ Promise.all(names.map(function (n) {
932
+ return call('graph.search', { q: n, limit: 3 }).then(function (d) {
933
+ var items = d.items || [];
934
+ var hit = items.find(function (it) { return String(it.id).toLowerCase() === n.toLowerCase(); }) || items.find(function (it) { return String(it.type || '').toLowerCase() === 'person' && String(it.id).toLowerCase().indexOf(n.toLowerCase()) === 0; });
935
+ if (!hit || seen[hit.id]) return null; seen[hit.id] = 1;
936
+ return call('graph.entity', { id: hit.id, limit: 6 }).then(function (en) { return en.found === false ? null : en; }, function () { return null; });
937
+ }, function () { return null; });
938
+ })).then(function (ents) {
939
+ if(serial!==graphQuestionSerial)return;
940
+ state.graphAsk.cards = ents.filter(Boolean); state.graphAsk.cardsBusy = false;
941
+ var h = document.querySelector('#graphAsk'); if (h) h.innerHTML = askBlockInner();
942
+ });
943
+ }
944
+ // The index stamps first_seen as epoch seconds; the inspector renders it as
945
+ // an age, the card as a date. Accept seconds, milliseconds or ISO.
946
+ function whenLabel(v) {
947
+ if (v == null || v === '') return '';
948
+ var n = typeof v === 'number' ? v : (/^\d{9,13}$/.test(String(v)) ? Number(v) : NaN);
949
+ var d = isNaN(n) ? new Date(String(v)) : new Date(n < 1e11 ? n * 1000 : n);
950
+ return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10);
951
+ }
952
+ function entityCardText(en) {
953
+ var desc = (en.descriptions && en.descriptions.length ? en.descriptions : (en.description ? [en.description] : [])).join('\n');
954
+ var rels = (en.edges || []).slice(0, 6).map(function (e) { var other = e.source === en.id ? e.target : e.source; return '- ' + other + (e.description ? ': ' + e.description : ''); }).join('\n');
955
+ return en.id + ' (' + (en.type || 'entity') + ', ' + fmt(en.degree || 0) + ' connections)\n' + desc + (rels ? '\nRelationships:\n' + rels : '');
956
+ }
957
+ function askEntityCard(en) {
958
+ var inView = state.graphFocus === en.id || (state.graphNeighbors || []).indexOf(en.id) !== -1;
959
+ var desc = (en.descriptions && en.descriptions.length ? en.descriptions[0] : en.description) || '';
960
+ var rels = (en.edges || []).slice(0, 3).map(function (e) { var other = e.source === en.id ? e.target : e.source; return '<div class="setup-source"><b>' + esc(other) + '</b><span class="muted">' + esc((e.description || '').slice(0, 140)) + '</span></div>'; }).join('');
961
+ return '<section class="ask-entity"><div class="row spread"><h4>' + esc(en.id) + '</h4><span class="meta">' + esc(en.type || 'entity') + ' · ' + fmt(en.degree || 0) + ' connections' + (en.created_at || en.first_seen_build ? ' · first indexed ' + esc(whenLabel(en.created_at || en.first_seen_build)) : '') + (inView ? ' · in view' : '') + '</span></div>' +
962
+ (desc ? '<p>' + esc(desc.length > 320 ? desc.slice(0, 320) + '…' : desc) + '</p>' : '<p class="muted">No description stored.</p>') + rels +
963
+ '<div class="setup-row"><button class="quiet" onclick="cosApp.copyAskEntity(' + attr(en.id) + ')">Copy context</button><button class="quiet" onclick="cosApp.askEntityPassages(' + attr(en.id) + ')">Show passages</button><button class="quiet" onclick="cosApp.focusEntity(' + attr(en.id) + ')">' + (inView ? 'Explore from here' : 'Explore from here (loads its neighborhood)') + '</button></div></section>';
964
+ }
965
+ var graphQuestionSerial=0;
966
+ var askTickTimer=null;
967
+ function askElapsed(startedAt){return startedAt?Math.max(0,Math.floor((Date.now()-startedAt)/1000)):0;}
968
+ function askBusyCopy(secs){
969
+ var phase = secs < 8 ? 'Searching the graph' : secs < 25 ? 'Choosing the relevant points' : 'Still working';
970
+ return phase + ' · ' + secs + 's · usually about a minute';
971
+ }
972
+ function askProgressHtml(startedAt){
973
+ return '<div class="ask-progress" role="status" aria-live="polite"><span class="ask-spin" aria-hidden="true"></span><span data-role="ask-progress-copy">' + esc(askBusyCopy(askElapsed(startedAt))) + '</span></div>';
974
+ }
975
+ function stopAskTick(){if(state.graphAsk.busy||state.askBusy)return;if(askTickTimer){clearInterval(askTickTimer);askTickTimer=null;}}
976
+ function tickAskProgress(){
977
+ if(!state.graphAsk.busy && !state.askBusy){stopAskTick();return;}
978
+ var secs = state.graphAsk.busy ? askElapsed(state.graphAsk.startedAt) : askElapsed(state.askStartedAt);
979
+ var copy = askBusyCopy(secs);
980
+ var nodes = document.querySelectorAll('[data-role="ask-progress-copy"]');
981
+ for (var i = 0; i < nodes.length; i++) nodes[i].textContent = copy;
982
+ var meta = document.querySelector('[data-role="ask-busy-meta"]');
983
+ if (meta) meta.textContent = 'working · ' + secs + 's';
984
+ }
985
+ function startAskTick(){if(askTickTimer){clearInterval(askTickTimer);askTickTimer=null;}askTickTimer=setInterval(tickAskProgress,1000);}
986
+ function paintAsk(){var host=document.querySelector('#graphAsk');if(host)host.innerHTML=askBlockInner();}
987
+ function askBlockInner() {
988
+ if(fileGraph())return '<section class="source-card ask-card"><h3>Ask the graph</h3><p>Model answers need an advanced graph pipeline. Explore your document links below or configure Knowledge in settings.</p></section>';
989
+
990
+ var a = state.graphAsk, focus = state.graphFocus, nbrs = state.graphNeighbors || [];
991
+ var suggested = !focus ? 'Ask about people, ideas, or their connections' : nbrs.length ? 'Show me the relation between ' + focus + ' and ' + nbrs[0] : 'What does the graph know about ' + focus + '?';
992
+ var chips = nbrs.slice(0, 4).map(function (n) { return '<button class="quiet chip" ' + (a.busy ? 'disabled' : '') + ' onclick="cosApp.askGraphAbout(' + attr(focus) + ', ' + attr(n) + ')">' + esc(focus) + ' ↔ ' + esc(n) + '</button>'; }).join('');
993
+ var names = [];
994
+ if (a.answer) { [focus].concat(nbrs).forEach(function (n) { if (n && a.answer.answer.indexOf(n) !== -1 && names.indexOf(n) === -1) names.push(n); }); }
995
+ var parsed = a.answer ? parseAskAnswer(a.answer.answer) : { prose: '', refs: [] };
996
+ return '<section class="source-card ask-card"' + (a.busy ? ' aria-busy="true"' : '') + '><div class="row spread"><h3>Ask the graph</h3><span class="meta"' + (a.busy ? ' data-role="ask-busy-meta"' : '') + '>' + (a.busy ? 'working · ' + askElapsed(a.startedAt) + 's' : 'plain language · about a minute') + '</span></div><p>Ask about a person, an idea, or how things connect. COS will choose the points and trace their connections.</p>' +
997
+ '<div class="setup-row"><input aria-label="Ask the graph" id="graphAskQ" oninput="cosApp.questionDraft(this.value)" value="' + esc(a.q) + '" placeholder="' + esc(suggested) + '" ' + (a.busy ? 'disabled' : '') + ' onkeydown="if(event.key===\'Enter\')cosApp.askGraphGo()"><button ' + (a.busy ? 'disabled' : '') + ' onclick="cosApp.askGraphGo()">' + (a.busy ? '<span class="ask-spin" aria-hidden="true"></span>Asking…' : 'Ask') + '</button></div>' +
998
+ (chips ? '<div class="setup-row chips">' + chips + '</div>' : '') +
999
+ (a.busy ? askProgressHtml(a.startedAt) : '') +
1000
+ (a.graphMessage ? '<p class="graph-question-status" role="status">' + esc(a.graphMessage) + '</p>' : '') +
1001
+ (a.error ? '<p class="bad">' + esc(a.error) + '</p>' : '') +
1002
+ (a.answer ? '<div class="setup-answer prose">' + renderMarkdown(parsed.prose) + '</div>' + askRefsHtml(parsed.refs) + '<div class="row spread"><p class="muted">' + (a.answer.elapsed_s != null ? Math.round(a.answer.elapsed_s) + ' s · ' : '') + esc(a.answer.mode || 'hybrid') + ' mode · synthesized from the graph, not a quote</p><span class="row"><button class="quiet" onclick="cosApp.copyAsk(false)">Copy answer</button><button class="quiet" onclick="cosApp.copyAsk(true)">Copy with context</button></span></div>' +
1003
+ (a.cardsBusy ? '<p class="muted">Looking up the entities it names…</p>' : (a.cards && a.cards.length ? '<h4 class="ask-cards-title">In the graph</h4><div class="ask-cards">' + a.cards.map(askEntityCard).join('') + '</div>' : '')) : '') +
1004
+ '</section>';
1005
+ }
1006
+ function nodeFromEntity(en) { return { id: en.id, group: en.type || 'unknown', descs: en.descriptions && en.descriptions.length ? en.descriptions : (en.description ? [en.description] : []), ts: en.created_at || null, totalDegree: en.degree || 0 }; }
1007
+ function neighborhood(focus) {
1008
+ return call('graph.entity', { id: focus, limit: 30 }).then(function (en) {
1009
+ if (en.found === false) throw Object.assign(new Error('No entity named ' + focus + ' in the graph.'), { status: 404 });
1010
+ var nodes = [nodeFromEntity(en)], seen = {}; seen[en.id] = 1;
1011
+ (en.neighbors || []).forEach(function (n) { if (!seen[n.id]) { seen[n.id] = 1; nodes.push({ id: n.id, group: n.type || 'unknown', descs: [], ts: null, totalDegree: n.degree || 0 }); } });
1012
+ var links = (en.edges || []).filter(function (e) { return seen[e.source] && seen[e.target]; }).map(function (e) { return { source: e.source, target: e.target, weight: e.weight || 1, desc: e.description || '' }; });
1013
+ var g = state.graphStatus || {};
1014
+ state.graphVisible = nodes.length;
1015
+ state.graphNeighbors = links.slice().sort(function (a, b) { return (b.weight || 0) - (a.weight || 0); }).map(function (l) { return l.source === focus ? l.target : l.source; }).filter(function (id, i, arr) { return id !== focus && arr.indexOf(id) === i; }).slice(0, 6);
1016
+ setTimeout(function () { var host = document.querySelector('#graphAsk'); if (host && !state.graphAsk.busy) host.innerHTML = askBlockInner(); }, 0);
1017
+ return { nodes: nodes, links: links, corpus_total_nodes: g.entities || nodes.length, corpus_total_edges: g.relationships || links.length, generated_at: g.index_built_at || null,
1018
+ description_scope: 'the ' + focus + ' neighborhood', selection_description: focus + ' and up to 30 direct neighbors, read live from this Mac\'s index' };
1019
+ });
1020
+ }
1021
+ // ── Curation (0.5.205, server 6.44.14): merge with a preview and a receipt; duplicates that only propose ──
1022
+ // Miles 2026-09-08: "Build the Manage merge path with the preview" and "address any of the obvious
1023
+ // duplicates like the miels and queen example from above without clobbering entities."
1024
+ state.merge = null; // { source, target, rule, preview, phase: loading|preview|starting|running|failed, receipt, logTail, armed, resolve }
1025
+ state.dups = { busy: false, data: null, error: null };
1026
+ var MERGE_POLL_MS = 3000;
1027
+ function mergeFlow(source, target, rule) {
1028
+ return new Promise(function (resolve) {
1029
+ if (state.merge && (state.merge.phase === 'running' || state.merge.phase === 'starting')) { toast('A merge is already running. Wait for its receipt.'); resolve(false); return; }
1030
+ state.merge = { source: source, target: target, rule: rule || null, preview: null, phase: 'loading', receipt: null, logTail: [], armed: false, resolve: resolve, startedAt: null };
1031
+ renderMerge(true);
1032
+ call('graph.merge.preview', { source: source, target: target }).then(function (pv) {
1033
+ var m = state.merge; if (!m || m.resolve !== resolve) return;
1034
+ m.preview = pv;
1035
+ if (pv.blocked) { finishMerge(false, pv.block_reason || 'Refused.'); return; }
1036
+ if (!pv.source || !pv.source.found || !pv.target || !pv.target.found) { finishMerge(false, 'No entity named ' + (pv.source && pv.source.found ? target : source) + ' in this Mac\'s index. Rebuild the index and try again.'); return; }
1037
+ m.phase = 'preview'; renderMerge(true);
1038
+ call('workspace.request',{action:'identity_status',source:source,target:target}).then(function(d){
1039
+ if(state.merge!==m)return;m.identity=d;renderMerge();
1040
+ },function(e){if(state.merge!==m)return;m.identityError=e.message;renderMerge();});
1041
+ }, function (e) { finishMerge(false, e.message); });
1042
+ });
1043
+ }
1044
+ function finishMerge(ok, message) {
1045
+ var m = state.merge; if (!m) return;
1046
+ state.merge = null;
1047
+ if (state.modalOpen === 'merge') closeModal();
1048
+ if (message) toast(message);
1049
+ if (m.resolve) m.resolve(ok);
1050
+ }
1051
+ // Updates the open sheet in place; opens it only when asked (a poll must never reopen a sheet the user closed).
1052
+ function renderMerge(open) {
1053
+ var m = state.merge; if (!m) return;
1054
+ var body = document.querySelector('#mergeBody');
1055
+ if (body && state.modalOpen === 'merge') { body.innerHTML = mergeSheetInner(); return; }
1056
+ if (!open) return;
1057
+ state.modalOpen = 'merge';
1058
+ modal('Merge ' + esc(m.source) + ' into ' + esc(m.target), '<div id="mergeBody">' + mergeSheetInner() + '</div>');
1059
+ }
1060
+ function mergeCard(en, keep) {
1061
+ var descs = (en.descriptions && en.descriptions.length) ? en.descriptions.map(function (d) { return '<p>' + esc(d) + '</p>'; }).join('') : '<p class="muted">No description extracted.</p>';
1062
+ var more = en.description_count > (en.descriptions || []).length ? '<p class="muted small">' + fmt(en.description_count - en.descriptions.length) + ' more description' + (en.description_count - en.descriptions.length === 1 ? '' : 's') + ' not shown</p>' : '';
1063
+ return '<div class="merge-col' + (keep ? ' keep' : '') + '"><h4>' + esc(en.id) + '</h4><div class="meta">' + (keep ? 'survives · ' : 'folds in · ') + esc(en.type || 'entity') + ' · ' + fmt(en.degree || 0) + ' connection' + (en.degree === 1 ? '' : 's') + (en.created_at ? ' · first indexed ' + esc(whenLabel(en.created_at)) : '') + '</div>' + descs + more + '</div>';
1064
+ }
1065
+ function mergeSteps(step) {
1066
+ var order = ['starting', 'locking', 'staging', 'snapshot', 'merging', 'indexing', 'exporting', 'done'];
1067
+ var labels = { starting: 'Starting the worker', locking: 'Taking the ingest lock', staging: 'Preparing an isolated copy of all graph stores', snapshot: 'Copying the graph aside', merging: 'Merging in LightRAG (re-embeds the merged texts)', indexing: 'Rebuilding this Mac\'s index', exporting: 'Refreshing the Observatory export', done: 'Done' };
1068
+ var at = Math.max(0, order.indexOf(step || 'starting'));
1069
+ return '<ol class="merge-steps">' + order.slice(0, -1).map(function (s, i) { return '<li class="' + (i < at ? 'done' : i === at ? 'now' : '') + '">' + esc(labels[s]) + '</li>'; }).join('') + '</ol>';
1070
+ }
1071
+ function mergeSheetInner() {
1072
+ var m = state.merge; if (!m) return '';
1073
+ if (m.phase === 'loading') return '<p class="muted">Asking this Mac what the merge would do…</p>';
1074
+ var pv = m.preview || {}, eff = pv.effect || {}, r = m.receipt || {};
1075
+ if (m.phase === 'preview' || m.phase === 'starting') {
1076
+ var big = (pv.warnings || []).some(function (w) { return w.code === 'large_merge'; });
1077
+ var unavailable = (pv.warnings || []).some(function (w) { return w.code === 'physical_merge_unavailable'; });
1078
+ var warn = (pv.warnings || []).map(function (w) { return '<p class="' + (w.code === 'large_merge' || w.code === 'nothing_links_them' ? 'bad' : 'muted') + '">' + esc(w.text) + '</p>'; }).join('');
1079
+ var mins = eff.estimated_seconds ? Math.max(1, Math.round(eff.estimated_seconds / 60)) : null;
1080
+ return '<p>Read both before merging. ' + (pv.name_signal ? '<span class="badge green">' + esc(pv.name_signal) + '</span>' : '<span class="badge">no name similarity</span>') + '</p>' +
1081
+ '<div class="merge-cmp">' + mergeCard(pv.target, true) + mergeCard(pv.source, false) + '</div>' +
1082
+ '<p class="muted">' + (pv.shared_count ? 'Shared neighbors: ' + esc((pv.shared_neighbors || []).join(', ')) + (pv.shared_count > (pv.shared_neighbors || []).length ? ' and ' + fmt(pv.shared_count - pv.shared_neighbors.length) + ' more' : '') : 'No shared neighbors.') + (pv.adjacent ? ' They are directly connected; that edge collapses.' : '') + '</p>' +
1083
+ '<p><b>Proposed effect:</b> ' + fmt(eff.moved || 0) + ' relationship' + (eff.moved === 1 ? '' : 's') + ' move to ' + esc(m.target) + ', ' + fmt(eff.collapsed || 0) + ' fold into ones it already has, ' + fmt(eff.embeddings || 0) + ' text' + (eff.embeddings === 1 ? '' : 's') + ' re-embedded' + (mins ? ', about ' + mins + ' minute' + (mins === 1 ? '' : 's') : '') + '. Original source documents remain unchanged. A graph-only copy does not restore every affected store.</p>' + warn +
1084
+ (m.rule ? '<p class="muted small">Rule recorded with the receipt: ' + esc(m.rule.scope || '') + ' "' + esc(m.rule.pattern || '') + '" → "' + esc(m.rule.replacement || '') + '".</p>' : '') +
1085
+ (m.identity && m.identity.writable && !m.identity.same_identity ? '<p class="small">If these are different people or ideas, keep them separate to block future merges of these alias groups.</p><button class="quiet" onclick="cosApp.keepApart()" ' + (m.phase === 'starting' ? 'disabled' : '') + '>Keep these separate</button>' : '') +
1086
+ (m.identityError ? '<p class="small muted">Identity constraints: ' + esc(m.identityError) + '</p>' : '') +
1087
+ '<div class="setup-row"><button class="quiet" onclick="cosApp.mergeCancel()" ' + (m.phase === 'starting' ? 'disabled' : '') + '>Cancel</button><button onclick="cosApp.mergeGo()" ' + (m.phase === 'starting' || unavailable ? 'disabled' : '') + '>' + (unavailable ? 'Merge unavailable' : m.phase === 'starting' ? 'Starting…' : (big && !m.armed ? 'Merge ' + fmt(pv.source.degree) + ' relationships…' : (m.armed ? 'Yes, merge ' + fmt(pv.source.degree) + ' relationships' : 'Merge on this Mac'))) + '</button></div>' +
1088
+ (big && m.armed ? '<p class="bad">Click again to confirm. This re-embeds ' + fmt(eff.embeddings || 0) + ' texts and rewrites the vector stores.</p>' : '');
1089
+ }
1090
+ if (m.phase === 'running') {
1091
+ var secs = m.startedAt ? Math.round((Date.now() - m.startedAt) / 1000) : 0;
1092
+ return '<p>Running on this Mac' + (r.ticket ? ' · ticket ' + esc(r.ticket) : '') + ' · ' + secs + ' s' + (eff.estimated_seconds ? ' of about ' + eff.estimated_seconds : '') + '</p>' + mergeSteps(r.step) +
1093
+ (m.logTail && m.logTail.length ? '<pre class="merge-log">' + esc(m.logTail.slice(-4).join('\n')) + '</pre>' : '') +
1094
+ '<p class="muted small">Runs in the background; you can close this and come back. The graph reloads around ' + esc(m.target) + ' when it is done.</p><div class="setup-row"><button class="quiet" onclick="cosApp.mergeHide()">Close</button></div>';
1095
+ }
1096
+ if (m.phase === 'failed') {
1097
+ return '<p class="bad">' + esc(r.error || 'The merge did not finish.') + '</p>' + mergeSteps(r.step) + (r.snapshot ? '<p class="muted small">The pre-merge copy of the graph is intact: ' + esc(r.snapshot) + '</p>' : '') +
1098
+ '<div class="setup-row"><button class="quiet" onclick="cosApp.mergeCancel()">Close</button></div>';
1099
+ }
1100
+ return '';
1101
+ }
1102
+ function pollMerge() {
1103
+ var m = state.merge; if (!m || m.phase !== 'running') return;
1104
+ setTimeout(function () {
1105
+ if (state.merge !== m || m.phase !== 'running') return;
1106
+ call('graph.merge.status').then(function (d) {
1107
+ if (state.merge !== m) return;
1108
+ m.receipt = d.receipt || m.receipt; m.logTail = d.log_tail || [];
1109
+ var st = m.receipt && m.receipt.state;
1110
+ if (st === 'done') { mergeDone(); return; }
1111
+ if (st === 'failed' || (!d.running && st !== 'done')) { m.phase = 'failed'; renderMerge(); if (state.modalOpen !== 'merge') toast('Merge of ' + m.source + ' failed: ' + ((m.receipt && m.receipt.error) || 'no detail')); return; }
1112
+ renderMerge(); pollMerge();
1113
+ }, function () { renderMerge(); pollMerge(); });
1114
+ }, MERGE_POLL_MS);
1115
+ }
1116
+ function mergeDone() {
1117
+ var m = state.merge; if (!m) return;
1118
+ var r = m.receipt || {}, after = r.after || {};
1119
+ finishMerge(true, 'Merged ' + m.source + ' into ' + m.target + (after.target != null ? ' · ' + fmt(after.target) + ' connections now' : '') + (r.embedded_texts != null ? ' · ' + fmt(r.embedded_texts) + ' texts re-embedded' : '') + '.');
1120
+ state.graphFocus = m.target; state.graphFocusChosen = true; state.graphQuery = '';
1121
+ state.graphMemories = {};
1122
+ loadGraphStatus().then(function () { render(); }, function () { render(); });
1123
+ if (state.dups.data) cosApp.duplicatesScan();
1124
+ }
1125
+ function dupGroup(g) {
1126
+ var t = g.members[0] || {};
1127
+ return '<div class="dup-group"><div class="row spread"><b>' + esc(g.target) + '</b><span class="meta">' + fmt(t.degree || 0) + ' connections · <span class="badge' + (g.confidence === 'high' ? ' green' : '') + '">' + esc(g.confidence) + '</span></span></div>' +
1128
+ (t.description ? '<p class="muted small">' + esc(t.description) + '</p>' : '') +
1129
+ g.members.slice(1).map(function (m) {
1130
+ return '<div class="setup-source dup-member"><span><b>' + esc(m.id) + '</b> <span class="muted">' + fmt(m.degree) + ' connection' + (m.degree === 1 ? '' : 's') + ' · ' + fmt(m.shared_neighbors) + ' shared' + (m.why ? ' · ' + esc(m.why) : '') + '</span>' + (m.description ? '<br><span class="muted small">' + esc(m.description) + '</span>' : '') + '</span><button class="quiet" onclick="cosApp.mergeFrom(' + attr(m.id) + ', ' + attr(g.target) + ')">Preview merge</button></div>';
1131
+ }).join('') + '</div>';
1132
+ }
1133
+ function duplicatesCard() {
1134
+ if(state.graphStatus && state.graphStatus.engine==='explicit_document_links')return '';
1135
+
1136
+ var d = state.dups;
1137
+ var head = '<div class="row spread"><h3>Possible duplicates</h3><span class="meta">people whose names look like one person · proposals only</span></div>';
1138
+ var rescan = '<div class="setup-row"><button class="quiet" onclick="cosApp.duplicatesScan()">Scan again</button></div>';
1139
+ var wrap = function (inner) { return '<section class="source-card dups-card">' + head + inner + '</section>'; };
1140
+ if (d.error) return wrap('<p class="bad">' + esc(d.error) + '</p>' + rescan);
1141
+ if (d.busy) return wrap('<p class="muted">Scanning this Mac\'s index…</p>');
1142
+ if (!d.data) return wrap('<p class="muted">Groups person entities whose names are variants of one another: a bare first name that matches one full name, a surname within two letters, one name spelling out the other. People the graph knows to be different never share a group. Nothing merges on its own; each row opens the same two-step preview as Manage.</p><div class="setup-row"><button class="quiet" onclick="cosApp.duplicatesScan()">Find possible duplicates</button></div>');
1143
+ var groups = d.data.groups || [];
1144
+ if (!groups.length) return wrap('<p class="muted">No name variants among ' + fmt(d.data.scanned || 0) + ' people' + (d.data.available === false ? ' (no index on this Mac yet)' : '') + '.</p>' + rescan);
1145
+ return wrap('<p class="muted">' + fmt(d.data.total_groups || groups.length) + ' group' + (d.data.total_groups === 1 ? '' : 's') + ' among ' + fmt(d.data.scanned || 0) + ' people' + (groups.length < (d.data.total_groups || 0) ? ', showing the first ' + groups.length : '') + '. Read both descriptions before merging: a merge is reviewed twice and refused for people the graph knows apart.</p>' + groups.map(dupGroup).join('') + rescan);
1146
+ }
1147
+ function paintDups() { var host = document.querySelector('#graphDups'); if (host) host.innerHTML = duplicatesCard(); }
1148
+
1149
+ function mountKnowledgeGraph() {
1150
+ var host = document.querySelector('#graphMount'); if (!host) return;
1151
+ if(window.COSMemoryWorkspace) {
1152
+ if(!state.workspace) state.workspace=window.COSMemoryWorkspace.create({call:call,toast:toast,openPassages:openPassages,onUserIntent:function(){state.graphFocusChosen=true;},onController:function(c){state.graphController=c;state.graphVisible=c.snapshot().nodes.length;var label=document.querySelector('[data-role="graph-visible"]');if(label)label.textContent=fmt(state.graphVisible)+' entities in this view';},graphOptions:{
1153
+ labels:{graphName:state.graphStatus && state.graphStatus.engine==='explicit_document_links'?'Your document links':'Your LightRAG data'},
1154
+ onCopy:function(text,label){call('copy',{text:text,label:label}).then(function(){toast('Copied as grounded context');},function(e){toast(e.message);});},
1155
+ memoriesFor:function(id){var hits=state.graphMemories[id];if(hits===undefined){state.graphMemories[id]=null;call('memories.search',{q:id,limit:5}).then(function(d){state.graphMemories[id]=(d.hits||[]).map(function(h){return {id:h.id,title:h.summary||h.content||h.id};});if(state.graphController)state.graphController.select(id);},function(){state.graphMemories[id]=[];});}return hits||[];},
1156
+ onOpenMemory:function(memoryId){state.view='learning';state.filter='memories';state.selectedMemory=memoryId;render();},
1157
+ curation:{enabled:!(state.graphStatus && state.graphStatus.engine==='explicit_document_links'),ownerLabel:hostLabel(state.graphStatus&&state.graphStatus.source&&state.graphStatus.source.owner_host)||'the owner Mac',isOwner:!!(state.graphStatus&&state.graphStatus.source&&state.graphStatus.source.is_owner)},
1158
+ onCuration:function(change){if(change.op!=='merge'){toast('Rename and remove are unavailable. Nothing was changed.');return false;}return mergeFlow(change.source,change.target,change.rule&&change.rule.pattern?change.rule:null);}
1159
+ }});
1160
+ state.workspace.attach(host,state.graphFocus); return;
1161
+ }
1162
+ if (state.graphController) { try { state.graphController.destroy(); } catch (e) {} state.graphController = null; }
1163
+ var focus = state.graphFocus;
1164
+ if (!focus) { host.textContent = 'Find a point above to explore its connections.'; return; }
1165
+ state.graphController = COSGraphExplorer.mount(host, {
1166
+ scope: 'actual', status: 'ready', focus: focus, endpoint: '',
1167
+ request: function (path) { if (path.indexOf('/api/graph') === 0) return neighborhood(focus); return Promise.reject(Object.assign(new Error('Not served by this host'), { status: 404 })); },
1168
+ labels: { sourceStatus: 'Descriptions are extracted summaries. Show passages reads the index behind this entity.' },
1169
+ memoriesFor: function (id) { var hits = state.graphMemories[id]; if (hits === undefined) { state.graphMemories[id] = null; call('memories.search', { q: id, limit: 5 }).then(function (d) { state.graphMemories[id] = (d.hits || []).map(function (h) { return { id: h.id, title: h.summary || h.content || h.id }; }); if (state.graphController && state.graphController.select) state.graphController.select(id); }, function () { state.graphMemories[id] = []; }); } return hits || []; },
1170
+ onOpenMemory: function (memoryId) { state.view = 'learning'; state.filter = 'memories'; state.selectedMemory = memoryId; render(); },
1171
+ onCopy: function (text, label) { call('copy', { text: text, label: label }).then(function () { toast('Copied as grounded context'); }, function (e) { toast(e.message); }); },
1172
+ onRecenter: function (id) { state.graphFocus = id; state.graphFocusChosen = true; state.graphQuery = ''; render(); },
1173
+ onPassages: function (id) { call('graph.passages', { entity: id, limit: 5 }).then(function (d) { openPassages(id, d); }, function (e) { toast(e.message); }); },
1174
+ curation: { ownerLabel: hostLabel(state.graphStatus && state.graphStatus.source && state.graphStatus.source.owner_host) || 'the owner Mac', isOwner: !!(state.graphStatus && state.graphStatus.source && state.graphStatus.source.is_owner) },
1175
+ // 0.5.205: Merge into… runs for real (server 6.44.14). The explorer's own comparison is
1176
+ // step one; the owner Mac's preview is step two; the receipt is polled until it settles.
1177
+ // The promise keeps the explorer's row "pending" and undoes it on a refusal or failure.
1178
+ onCuration: function (change) {
1179
+ if (change.op !== 'merge') { toast('Rename and remove arrive in a later release. Nothing was changed.'); return false; }
1180
+ return mergeFlow(change.source, change.target, change.rule && change.rule.pattern ? change.rule : null);
1181
+ }
1182
+ });
1183
+ // "Visible" in the Sync card counts what the neighborhood returned.
1184
+ setTimeout(function () { var dd = document.querySelector('.sync-grid'); if (dd && state.graphVisible != null) { var cells = dd.querySelectorAll('dd'); if (cells[4]) cells[4].textContent = fmt(state.graphVisible) + ' entities in this view'; } }, 1500);
1185
+ }
1186
+ state.graphMemories = {};
1187
+ function openPassages(id, d) {
1188
+ var items = d.items || [];
1189
+ modal('Passages · ' + esc(id), (d.note ? '<p>' + esc(d.note) + '</p>' : '') + (items.length ? items.map(function (it) { var s = it.source || {}; return '<div class="logrow">' + esc(s.title || 'Passage') + (s.date ? ' · ' + esc(s.date) : '') + (s.status && s.status !== 'resolved' ? ' · ' + esc(s.status) : '') + '<small>' + esc(it.excerpt) + '</small></div>'; }).join('') : '<p>No passages behind this entity in the index.</p>') + '<p class="muted">' + fmt(d.total || 0) + ' passage' + (d.total === 1 ? '' : 's') + ' in the index' + (d.fallback ? ' · read from ' + esc(d.fallback) : '') + '.</p>');
1190
+ }
1191
+ function graphSearch() {
1192
+ var q = (document.querySelector('#graphQuery') || {}).value || ''; q = q.trim(); state.graphQuery = q;
1193
+ var status = document.querySelector('#graphSearchStatus');
1194
+ if (q.length < 2) { if (status) status.textContent = 'Type at least two characters.'; return; }
1195
+ state.graphFocusChosen = true;
1196
+ if (status) status.textContent = 'Looking up…';
1197
+ call('graph.search', { q: q, limit: 5 }).then(function (d) {
1198
+ var items = d.items || [];
1199
+ if (!items.length) { if (status) status.textContent = d.indexState === 'missing' ? 'No knowledge index on this Mac yet. Build it above.' : 'No entities match that lookup.'; return; }
1200
+ if(state.workspace) { state.workspace.showMatches(items); if(status) status.textContent=items.length+' matches · select a point below'; return; }
1201
+ state.graphFocus = items[0].id; state.graphFocusChosen = true; render();
1202
+ }, function (e) { if (status) status.textContent = e.message; });
1203
+ }
1204
+ function exploreTerm(e) { if (e.category && e.category !== e.scope && e.category !== 'unknown') return e.category; return String(e.title || '').split(' ').slice(0, 4).join(' ') || 'COS'; }
1205
+ function exploreInGraph(id) {
1206
+ var e = findEvent(id); if (!e) return;
1207
+ state.knowledgeFocus = id; state.view = 'knowledge'; state.knowledgeTab = 'graph'; state.graphQuery = exploreTerm(e); render();
1208
+ call('graph.search', { q: state.graphQuery, limit: 3 }).then(function (d) { var items = d.items || []; if (items.length) { state.graphFocus = items[0].id; state.graphFocusChosen = true; render(); } else toast('No entity in the graph matches "' + state.graphQuery + '". Showing ' + state.graphFocus + '.'); }, function (er) { toast(er.message); });
1209
+ }
1210
+ function findEvent(id) { return state.recent.concat(state.review).find(function (x) { return x.event_id === id; }); }
1211
+
1212
+ // ── decisions (the one write with a backend tonight) ────────────
1213
+ state.decisions = {};
1214
+ function decide(id, decision) {
1215
+ var e = findEvent(id); if (!e) return;
1216
+ call('learning.decide', { id: e.lesson_id, decision: decision }).then(function () {
1217
+ state.decisions[e.lesson_id] = decision;
1218
+ toast(decision === 'dismissed' ? 'Proposal dismissed. The To review count updates on the next refresh.' : 'Proposal restored.');
1219
+ call('learning.review', { limit: 200 }).then(function (d) { state.review = d.events || []; state.reviewCount = d.reviewCount != null ? d.reviewCount : d.total; render(); }, function () { render(); });
1220
+ }, function (er) { toast(er.message); });
1221
+ }
1222
+
1223
+ // ── copy context ────────────────────────────────────────────────
1224
+ function contextText(e, opts) {
1225
+ var full = state.detail[e.event_id]; var x = Object.assign({}, e, full && !full._error ? full : {});
1226
+ var lines = ['Learning ' + x.event_id + ' (' + kindLabel(x) + ' · ' + (x.scope || '') + ' · ' + (x.ts || '') + ')', '', '"""', x.title || ''];
1227
+ var d = x.detail || {};
1228
+ if (d.before) lines.push('Before: ' + d.before); if (d.after) lines.push('After: ' + d.after); if (d.rule) lines.push('Rule: ' + d.rule); if (d.content) lines.push(d.content); (d.bodies || []).forEach(function (b) { lines.push(b); });
1229
+ lines.push('"""');
1230
+ if (opts.sources && (x.source_refs || []).length) { lines.push('', 'Sources:'); x.source_refs.forEach(function (r) { lines.push('- ' + (r.kind || '') + ' ' + (r.id || '') + (r.excerpt ? ': ' + r.excerpt : '')); }); }
1231
+ var o = outcomeText(x); if (o) lines.push('', 'Checked: ' + o);
1232
+ if (opts.graph) lines.push('', 'Related knowledge: open the entity in Knowledge and use its Copy context for descriptions and relationships.');
1233
+ return lines.join('\n');
1234
+ }
1235
+ function openCopyContext(id) {
1236
+ state.copyId = id; var e = findEvent(id); if (!e) return;
1237
+ modal('Copy context', '<p>Preview the exact text, then copy it. Sources are included by default.</p><div class="copy-options"><label><input type="checkbox" ' + (state.copyOptions.sources ? 'checked' : '') + ' onchange="cosApp.copyOption(\'sources\',this.checked)"> Include sources</label><label><input type="checkbox" ' + (state.copyOptions.graph ? 'checked' : '') + ' onchange="cosApp.copyOption(\'graph\',this.checked)"> Include a related-knowledge note</label></div><textarea class="editor context-preview" id="contextPreview" readonly>' + esc(contextText(e, state.copyOptions)) + '</textarea><div class="actions"><button class="primary" onclick="cosApp.copyNow()">Copy</button><button onclick="cosApp.selectContextText()">Select text</button></div><p class="copy-footnote muted">Quoted and labelled with its id: the same data-not-instructions contract the glasses use when they attach a reference.</p>');
1238
+ }
1239
+ function copyOption(k, v) { state.copyOptions[k] = v; var e = findEvent(state.copyId); var ta = document.querySelector('#contextPreview'); if (e && ta) ta.value = contextText(e, state.copyOptions); }
1240
+ function copyNow() { var e = findEvent(state.copyId); if (!e) return; call('copy', { text: contextText(e, state.copyOptions), label: 'Copy context' }).then(function () { toast('Copied as grounded context'); closeModal(); }, function (er) { toast(er.message); }); }
1241
+ function selectContextText() { var ta = document.querySelector('#contextPreview'); if (ta) { ta.focus(); ta.select(); } }
1242
+ function copyMemory(id) {
1243
+ var m = (state.memoryHits || state.memories).find(function (x) { return x.id === id; }) || {}; var full = state.memoryDetail[id] || {}; var r = Object.assign({}, m, full._error ? {} : full);
1244
+ var text = 'Memory ' + id + (r.type || r.created_at ? ' (' + [r.type, dateOnly(r.created_at)].filter(Boolean).join(' · ') + ')' : '') + '\n\n"""\n' + (r.content || r.summary || '') + '\n"""';
1245
+ call('copy', { text: text, label: 'Copy memory' }).then(function () { toast('Copied as grounded context'); }, function (er) { toast(er.message); });
1246
+ }
1247
+
1248
+ // ── modals ──────────────────────────────────────────────────────
1249
+ function modal(title, body) {
1250
+ if (!document.querySelector('.modal')) window.previousFocus = document.activeElement;
1251
+ document.body.classList.add('modal-open');
1252
+ document.querySelector('#modalRoot').innerHTML = '<div class="modal-backdrop" onclick="if(event.target===this)cosApp.closeModal()"><section class="modal" role="dialog" aria-modal="true" aria-label="' + esc(title) + '"><div class="row spread"><h2>' + title + '</h2><button class="quiet" aria-label="Close dialog" onclick="cosApp.closeModal()">✕</button></div>' + body + '</section></div>';
1253
+ var first = document.querySelector('.modal button'); if (first) first.focus();
1254
+ }
1255
+ function closeModal() {
1256
+ if(state.modalOpen==='stewardship'&&state.stewardship)state.stewardship.close();
1257
+ if (state.modalOpen === 'merge' && state.merge && (state.merge.phase === 'preview' || state.merge.phase === 'loading')) { var pending = state.merge; state.merge = null; if (pending.resolve) pending.resolve(false); toast('Nothing was changed.'); }
1258
+ state.modalOpen = null; document.body.classList.remove('modal-open'); var existed = !!document.querySelector('.modal'); document.querySelector('#modalRoot').innerHTML = ''; if (existed && window.previousFocus && window.previousFocus.isConnected && ['BODY', 'HTML'].indexOf(window.previousFocus.tagName) < 0) window.previousFocus.focus(); }
1259
+ function openSourceRecords() {
1260
+ state.view = 'knowledge'; state.knowledgeTab = 'sources'; state.knowledgeTabChosen = true; render();
1261
+ var el = document.querySelector('#sourceRecords'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start' });
1262
+ }
1263
+ // Guardrails (0.5.201): the user's own rules for what a captured memory must
1264
+ // be, an optional model pass against the philosophy, and a review-now run.
1265
+ function loadGuardrails() {
1266
+ if (state.guardrailsLoading) return;
1267
+ state.guardrailsLoading = true;
1268
+ call('memory.guardrails').then(function (d) { state.guardrails = d.guardrails; state.guardrailsRubricLines = d.philosophy_rubric_lines; state.guardrailsError = null; state.guardrailsLoading = false; renderModalIfOpen(); },
1269
+ function (e) { state.guardrailsError = e.message; state.guardrailsLoading = false; renderModalIfOpen(); });
1270
+ }
1271
+ function renderModalIfOpen() { if (state.modalOpen === 'settings') openSettings(true); }
1272
+ function guardrailsSection() {
1273
+ var g = state.guardrails, busy = state.guardrailsBusy;
1274
+ if (state.guardrailsError) return '<section class="settings-block"><h3>Guardrails</h3><p class="muted">' + esc(state.guardrailsError) + '</p><button class="quiet" onclick="cosApp.guardrailsLoad()">Retry</button></section>';
1275
+ if (!g) { if (!state.guardrailsLoading) loadGuardrails(); return '<section class="settings-block"><h3>Guardrails</h3><p class="muted">Loading…</p></section>'; }
1276
+ var llm = g.llm_review || {};
1277
+ var run = state.guardrailsRun, verdicts = run ? (run.verdicts || []) : [];
1278
+ var flagged = verdicts.filter(function (v) { return v.verdict === 'prune'; }), review = verdicts.filter(function (v) { return v.verdict === 'review'; });
1279
+ var rows = flagged.concat(review).slice(0, 60).map(function (v) {
1280
+ return '<div class="setup-source"><span class="' + (v.verdict === 'prune' ? 'bad' : '') + '">' + (v.verdict === 'prune' ? (v.applied ? 'quarantined' : 'prune') : 'for you') + '</span><code>' + esc(v.excerpt || v.id) + '</code><span class="muted">' + esc((v.reasons || []).join('; ')) + (v.by === 'model' ? ' (model)' : '') + '</span></div>';
1281
+ }).join('');
1282
+ return '<section class="settings-block"><h3>Guardrails</h3>' +
1283
+ '<p class="muted">What a captured memory must be to stay. The same rules refuse nonsense at capture time; a run applies them to what already landed.</p>' +
1284
+ '<div class="setup-row"><label>Min words <input id="grMinWords" type="number" min="0" max="200" value="' + esc(g.min_words) + '"></label>' +
1285
+ '<label>Min distinct chars <input id="grDistinct" type="number" min="0" max="100" value="' + esc(g.min_distinct_chars) + '"></label>' +
1286
+ '<label>Max repeat ratio <input id="grRepeat" type="number" min="0.1" max="1" step="0.05" value="' + esc(g.max_repeat_ratio) + '"></label></div>' +
1287
+ '<label class="muted">Banned patterns, one per line (regular expressions)</label><textarea id="grBanned" rows="3">' + esc((g.banned_patterns || []).join('\n')) + '</textarea>' +
1288
+ (llm.supported===false ? '<p class="muted">This runtime applies deterministic admission rules. A model review provider is not configured.</p>' : '<div class="setup-row"><label><input id="grLlm" type="checkbox" ' + (llm.enabled ? 'checked' : '') + '> Model pass against the COS philosophy (' + fmt(state.guardrailsRubricLines || 0) + ' principles)</label>' +
1289
+ '<select id="grModel">' + ['haiku', 'sonnet', 'opus'].map(function (m) { return '<option value="' + m + '"' + (llm.model === m ? ' selected' : '') + '>' + ({ haiku: 'Fast (Haiku)', sonnet: 'Balanced (Sonnet)', opus: 'Deep (Opus)' })[m] + '</option>'; }).join('') + '</select>' +
1290
+ '<label>at most <input id="grMax" type="number" min="1" max="100" value="' + esc(llm.max_per_run || 20) + '"> per run</label></div>' +
1291
+ '<p class="muted">The model pass makes one bounded provider request per reviewed memory and stops at the configured cap. Off by default.</p>') +
1292
+ '<div class="setup-row"><button ' + (busy ? 'disabled' : '') + ' onclick="cosApp.guardrailsSave()">' + (busy === 'save' ? 'Saving…' : 'Save guardrails') + '</button>' +
1293
+ '<button class="quiet" ' + (busy ? 'disabled' : '') + ' onclick="cosApp.guardrailsRun(false)">' + (busy === 'run' ? 'Reviewing…' : 'Review captured memories now') + '</button>' +
1294
+ (flagged.length && !run.run.applied ? '<button class="quiet" ' + (busy ? 'disabled' : '') + ' onclick="cosApp.guardrailsRun(true)">' + (busy === 'apply' ? 'Pruning…' : 'Review next batch and quarantine matches') + '</button>' : '') + '</div>' +
1295
+ (run ? '<p class="muted">Last run: ' + fmt(run.run.scanned) + ' scanned in ' + fmt(run.run.days) + ' days, ' + fmt(run.run.flagged) + ' flagged, ' + fmt(run.run.kept) + ' kept' + (run.run.review ? ', ' + fmt(run.run.review) + ' for you' : '') + (run.run.llm_reviewed ? ', ' + fmt(run.run.llm_reviewed) + ' judged by the model' : '') + (run.run.applied ? ', ' + fmt(run.run.pruned) + ' quarantined' : '') + (run.run.coverage?', '+fmt(run.run.coverage.remaining_after_page)+' remain in this pass':'') + '.</p>' + rows : '') +
1296
+ '</section>';
1297
+ }
1298
+ function loadPolicy() {
1299
+ if(state.policyLoading||state.policyBusy)return;state.policyLoading=true;var serial=++policyRequestSerial;
1300
+ call('workspace.request',{action:'policy_get'}).then(function(d){if(serial!==policyRequestSerial)return;state.policyData=d;state.policyError=null;state.policyLoading=false;renderModalIfOpen();},function(e){if(serial!==policyRequestSerial)return;state.policyError=e.message;state.policyLoading=false;renderModalIfOpen();});
1301
+ }
1302
+ function policySet(key,value) {
1303
+ if(state.policyBusy)return;
1304
+ if(!state.policyIntent){
1305
+ var changes={};changes[key]=value;
1306
+ state.policyIntent={action:'policy_set',changes:changes,expected_revision:state.policyData.policy.revision,
1307
+ idempotency_key:'policy_'+crypto.randomUUID()};
1308
+ }
1309
+ policyRequestSerial++;state.policyLoading=false;state.policyBusy=true;state.policyError=null;renderModalIfOpen();
1310
+ call('workspace.request',state.policyIntent).then(function(d){
1311
+ state.policyData={policy:d.policy,writable:true,state:'available'};state.policyIntent=null;state.policyBusy=false;
1312
+ toast('Memory policy saved.');renderModalIfOpen();
1313
+ },function(e){state.policyBusy=false;state.policyError=e.message;
1314
+ if(/revision_conflict|idempotency_conflict|unknown_policy|invalid_policy/.test(e.message)){state.policyIntent=null;state.policyError='Policy changed or request was refused. Reload settings, review the current value, then choose again.';}
1315
+ renderModalIfOpen();});
1316
+ }
1317
+ function ownerCard(){
1318
+ var name=state.ownerDraft!=null?state.ownerDraft:(state.ownerProfile&&state.ownerProfile.owner_name)||'';
1319
+ return '<section class="setting owner-card"><h3>Who is COS for?</h3><p>Your name personalizes COS and the graph’s starting point. It does not change the indexing Mac or transfer memory ownership.</p><div class="row"><input id="ownerName" aria-label="Owner name" placeholder="Your full name" maxlength="120" value="'+esc(name)+'" oninput="cosApp.ownerDraft(this.value)"><button '+(!state.ownerProfile||state.ownerBusy?'disabled':'')+' onclick="cosApp.saveOwner()">'+(state.ownerBusy?'Saving…':'Save name')+'</button></div>'+(state.ownerError?'<p class="bad">'+esc(state.ownerError)+'</p><button onclick="cosApp.loadOwner()">Retry</button>':'')+'</section>';
1320
+ }
1321
+ var ownerReadSerial=0,ownerDraftRevision=0;
1322
+ function loadOwner(){
1323
+ if(state.ownerBusy)return;
1324
+ var serial=++ownerReadSerial;
1325
+ state.ownerError=null;
1326
+ call('profile.owner',{}).then(function(d){if(serial!==ownerReadSerial)return;state.ownerProfile=d;if(state.ownerDraft==null)state.ownerDraft=d.owner_name||'';renderModalIfOpen();},function(e){if(serial!==ownerReadSerial)return;state.ownerError=e.message;renderModalIfOpen();});
1327
+ }
1328
+ function openSettings(rerender) {
1329
+ state.modalOpen='settings';if(!rerender){loadPolicy();loadGuardrails();loadOwner();}
1330
+ var s=state.status||{},d=state.policyData,p=d&&d.policy;
1331
+ var tier=s.contextScriptsDirectory?'COS Data bridge (vector store + files)':s.contextFilesDirectory?'Plain files':'Not configured';
1332
+ function row(key,title,sub){var on=p&&p[key],disabled=!d||!d.writable||state.policyBusy||state.policyIntent;
1333
+ return '<div class="setting row spread"><div><h3>'+title+'</h3><small>'+sub+'</small></div><button class="switch '+(on?'on':'')+'" role="switch" aria-checked="'+!!on+'" aria-label="'+title+'" '+(disabled?'disabled':'')+' onclick="cosApp.policySet('+attr(key)+','+!on+')"><span></span></button></div>';}
1334
+ var policyBlock=p?row('capture_enabled','Capture completed sessions','Pausing affects new automatic captures. Existing knowledge remains readable.')+
1335
+ row('review_required','Review new memories before active recall','Captured candidates wait for your acceptance. Existing accepted memories remain active.')+
1336
+ '<p class="muted">Policy revision '+esc(p.revision)+'. Instance owner only. Skill changes require a reviewed, evaluated version.</p>':'<p>Loading effective memory policy…</p>';
1337
+ if(d&&!d.writable)policyBlock+='<p>Memory policy is read-only: '+esc(d.state)+'.</p>';
1338
+ if(state.policyError)policyBlock+='<p class="bad">'+esc(state.policyError)+'</p><button onclick="cosApp.'+(state.policyIntent?'policyRetry':'policyLoad')+'()">Retry</button>';
1339
+ modal('Memory settings',ownerCard()+'<p>Control capture and activation. Retrieval treats source text as evidence, never as instructions.</p>'+policyBlock+
1340
+ '<div class="actions"><button onclick="cosApp.openStewardship()">Rules, current sources and outcome evidence</button></div><div class="setting"><h3>Current storage source</h3><small>'+esc(tier)+(s.contextResolvedRoot?' · '+esc(s.contextResolvedRoot):'')+'</small></div>'+guardrailsSection());
1341
+ }
1342
+ function openLog() {
1343
+ var ls = state.learningStatus || {};
1344
+ var counts = ls.counts_by_type || {};
1345
+ var rows = Object.keys(counts).sort().map(function (k) { return '<div class="logrow">' + esc(KIND[k] || k) + '<small>' + fmt(counts[k]) + ' in the window</small></div>'; }).join('');
1346
+ var stores = Object.keys(ls.stores || {}).map(function (k) { var st = ls.stores[k]; return '<div class="logrow">' + esc(STORE[k] || k) + '<small>' + (st.readable || st.state === 'legacy' ? 'readable' + (st.state === 'legacy' ? ' (legacy sink)' : '') + ' · ' + fmt(st.count || 0) + (st.last_ts ? ' · last ' + esc(stamp(st.last_ts)) : '') : 'not readable (' + esc(st.state || '') + ')') + '</small></div>'; }).join('');
1347
+ // 0.5.216: name the lessons a later answer used in the Applied window, not just
1348
+ // type counts. Capped; overflow points at the Applied filter. Never a score.
1349
+ var used = state.applied || [], cap = 10;
1350
+ var usedRows = used.slice(0, cap).map(function (e) { var l = lessonText(e.lesson_id) || e.lesson_id || ''; var x = appliedExcerpt(e); return '<div class="logrow">' + esc(String(l).slice(0, 90)) + '<small>' + esc(String(x).slice(0, 110) || 'used by a later answer') + '</small></div>'; }).join('');
1351
+ var usedHead = '<h3 style="margin-top:14px">Applied ' + (state.appliedDays === 90 ? 'in 90 days' : 'this week') + '</h3>';
1352
+ var windowTotal = Math.max(state.appliedTotal != null ? Number(state.appliedTotal) : 0, used.length);
1353
+ var usedBody = used.length ? usedRows + (windowTotal > cap ? '<p class="muted">and ' + fmt(windowTotal - cap) + ' more in Applied ' + (state.appliedDays === 90 ? 'in 90 days' : 'this week') + '.</p>' : '') : '<p class="muted">' + esc(appliedEmptyCopy().h) + '</p>';
1354
+ modal('Recent learning activity', '<p>' + esc(ls.notice || ('Snapshot from '+stamp(ls.generated_at)+'. Window: '+stamp(ls.window_start)+' to '+stamp(ls.window_end)+'. Coverage: '+(ls.complete?'complete':'partial')+'.')) + '</p>' + usedHead + usedBody + '<h3 style="margin-top:14px">Event counts</h3>' + (rows || '<p>No events counted.</p>') + '<h3 style="margin-top:14px">Stores</h3>' + stores);
1355
+ }
1356
+
1357
+ // ── public surface for inline handlers ──────────────────────────
1358
+ window.cosApp = {
1359
+ ownerDraft:function(value){ownerDraftRevision++;state.ownerDraft=value;},
1360
+ loadOwner:loadOwner,
1361
+ saveOwner:function(){
1362
+ if(state.ownerBusy||!state.ownerProfile)return;
1363
+ var name=(state.ownerDraft||'').trim();if(!name){state.ownerError='Enter your name.';renderModalIfOpen();return;}
1364
+ ownerReadSerial++;var draftRevision=ownerDraftRevision;
1365
+ state.ownerBusy=true;state.ownerError=null;renderModalIfOpen();
1366
+ call('profile.owner.set',{name:name,expected:state.ownerProfile.owner_name||''}).then(function(d){
1367
+ state.ownerBusy=false;state.ownerProfile=d;if(draftRevision===ownerDraftRevision)state.ownerDraft=d.owner_name;state.status=state.status||{};state.status.ownerName=d.owner_name;
1368
+ state.graphFocusResolved=false;chooseDefaultFocus();renderModalIfOpen();toast('Owner name saved.');
1369
+ },function(e){state.ownerBusy=false;state.ownerError=e.message;renderModalIfOpen();});
1370
+ },
1371
+ openStewardship:function(){if(!state.stewardship)state.stewardship=createMemoryStewardship({call:call,esc:esc,modal:modal,toast:toast});state.modalOpen='stewardship';state.stewardship.open();},
1372
+ reviewGoverned:reviewGoverned,loadMoreReview:function(){loadReview(true);},
1373
+ policySet:policySet,policyRetry:function(){policySet();},policyLoad:loadPolicy,
1374
+ select: function (id) { state.selected = id; render(); },
1375
+ selectMemory: function (id) { state.selectedMemory = id; render(); },
1376
+ setFilter: function (f) { state.view = 'learning'; state.filter = f; if (f === 'applied' && !state.appliedLoaded && !state.appliedLoading) loadApplied(false); render(); },
1377
+ appliedDays: setAppliedDays, loadMoreApplied: function () { loadApplied(true); }, retryApplied: function () { state.errors.applied = null; state.appliedLoaded = false; loadApplied(false); render(); },
1378
+ openCues: function (view) { call('cues.open', { view: view === 'rated' ? 'rated' : 'rate' }).catch(function (e) { toast(e.message); }); },
1379
+ openKnowledge: function (id) { state.view = 'knowledge'; state.knowledgeTab = id ? 'sources' : 'graph'; state.knowledgeFocus = id || null; render(); },
1380
+ setKnowledgeTab: function (t) { state.knowledgeTab = t; state.knowledgeTabChosen = true; render(); },
1381
+ openSourceRecords: openSourceRecords,
1382
+ backToLearning: function () { state.view = 'learning'; state.filter = 'recent'; if (state.knowledgeFocus) state.selected = state.knowledgeFocus; render(); },
1383
+ exploreInGraph: exploreInGraph, graphSearch: graphSearch,
1384
+ refresh: loadAll, loadMoreMemories:function(){loadMemoryPage(true);}, refreshGraph: loadGraphStatus, loadMore: loadMore, memoryQuery: memoryQuery,
1385
+ setupRefresh: function () { loadSetup(); },
1386
+ questionDraft:function(value){state.graphAsk.q=value;},
1387
+ askGraphAbout: function (a, b) { cosApp.askGraphGo('Show me the relation between ' + a + ' and ' + b); },
1388
+ askGraphGo: function (question) {
1389
+ if(state.graphAsk.busy)return;
1390
+ var input=document.querySelector('#graphAskQ'),q=typeof question==='string'?question:(input?input.value.trim():'');
1391
+ if(!q||q.length<3){toast('Ask a fuller question.');return;}
1392
+ if(q.length>400){toast('Keep the question under 400 characters.');return;}
1393
+ var serial=++graphQuestionSerial,workspace=state.workspace,token=workspace&&workspace.questionToken();
1394
+ state.graphFocusChosen=true;
1395
+ state.graphAsk={q:q,busy:true,answer:null,error:null,cards:[],cardsBusy:false,startedAt:Date.now()};paintAsk();startAskTick();
1396
+ call('graph.ask',{q:q}).then(function(d){
1397
+ if(serial!==graphQuestionSerial)return;
1398
+ var message=d.investigation&&d.investigation.message;
1399
+ if(d.investigation&&d.investigation.status==='ready'&&workspace){
1400
+ message=workspace.applyQuestion(d.investigation,token)?'The graph now shows the points and connections chosen for your question.':'Your graph edits were kept. Ask again when you are ready to update the view.';
1401
+ }else if(!message)message='This answer has no verified graph plan. Use Advanced investigation to choose points.';
1402
+ state.graphAsk={q:q,busy:false,answer:d,error:null,cards:[],cardsBusy:false,graphMessage:message};stopAskTick();paintAsk();loadAskCards(d.answer||'',q,serial);
1403
+ },function(e){if(serial!==graphQuestionSerial)return;state.graphAsk={q:q,busy:false,answer:null,error:e.message,cards:[],cardsBusy:false};stopAskTick();paintAsk();});
1404
+ },
1405
+ focusEntity: function (id) { state.graphFocus = id; state.graphFocusChosen = true; state.graphQuery = ''; render(); },
1406
+ keepApart: function () {
1407
+ var m=state.merge;if(!m || m.phase!=='preview' || !m.identity || !m.identity.writable)return;
1408
+ if(!m.constraintIntent)m.constraintIntent={action:'identity_keep_apart',source:m.source,target:m.target,
1409
+ expected_revision:m.identity.revision,idempotency_key:'keep-apart:'+crypto.randomUUID()};
1410
+ m.phase='starting';renderMerge();
1411
+ call('workspace.request',m.constraintIntent).then(function(){
1412
+ if(state.merge!==m)return;finishMerge(false,'Saved. These identities will be kept separate.');loadGraphStatus();
1413
+ },function(e){if(state.merge!==m)return;m.phase='preview';renderMerge();toast(e.message);
1414
+ if(/revision_conflict|identity_split_required|idempotency_conflict/.test(e.message)){m.constraintIntent=null;call('workspace.request',{action:'identity_status',source:m.source,target:m.target}).then(function(d){if(state.merge===m){m.identity=d;renderMerge();}},function(){});}
1415
+ });
1416
+ },
1417
+ mergeGo: function () {
1418
+ var m = state.merge; if (!m || m.phase !== 'preview') return;
1419
+ var unavailable = ((m.preview || {}).warnings || []).find(function (w) { return w.code === 'physical_merge_unavailable'; });
1420
+ if (unavailable) { toast(unavailable.text); return; }
1421
+ var big = ((m.preview || {}).warnings || []).some(function (w) { return w.code === 'large_merge'; });
1422
+ if (big && !m.armed) { m.armed = true; renderMerge(); return; }
1423
+ m.phase = 'starting'; renderMerge();
1424
+ call('graph.merge', { source: m.source, target: m.target, confirm: true, rule: m.rule || undefined }).then(function (d) {
1425
+ if (state.merge !== m) return;
1426
+ m.phase = 'running'; m.receipt = d.receipt || null; m.ticket = d.ticket || null; m.startedAt = Date.now(); renderMerge(); pollMerge();
1427
+ }, function (e) { if (state.merge !== m) return; m.phase = 'preview'; m.armed = false; renderMerge(); toast(e.message); });
1428
+ },
1429
+ mergeCancel: function () { finishMerge(false, 'Nothing was changed.'); },
1430
+ mergeHide: function () { closeModal(); },
1431
+ mergeFrom: function (source, target) { mergeFlow(source, target, null); },
1432
+ duplicatesScan: function () {
1433
+ if (state.dups.busy) return;
1434
+ state.dups = { busy: true, data: null, error: null }; paintDups();
1435
+ call('graph.duplicates', { limit: 25 }).then(function (d) { state.dups = { busy: false, data: d, error: null }; paintDups(); }, function (e) { state.dups = { busy: false, data: null, error: e.message }; paintDups(); });
1436
+ },
1437
+ copyAsk: function (withContext) {
1438
+ var a = state.graphAsk; if (!a.answer) return;
1439
+ var text = 'Question: ' + a.q + '\n\nAnswer (synthesized from the COS knowledge graph, ' + (a.answer.mode || 'hybrid') + ' mode):\n' + a.answer.answer;
1440
+ if (withContext && a.cards && a.cards.length) text += '\n\nEntities in the graph:\n\n' + a.cards.map(entityCardText).join('\n\n');
1441
+ call('copy', { text: text }).then(function () { toast(withContext ? 'Copied the answer with its entity context.' : 'Copied the answer.'); }, function (e) { toast(e.message); });
1442
+ },
1443
+ copyAskEntity: function (id) { var en = (state.graphAsk.cards || []).find(function (c) { return c.id === id; }); if (!en) return; call('copy', { text: entityCardText(en) }).then(function () { toast('Copied ' + id + ' as context.'); }, function (e) { toast(e.message); }); },
1444
+ askEntityPassages: function (id) { call('graph.passages', { entity: id, limit: 5 }).then(function (d) { openPassages(id, d); }, function (e) { toast(e.message); }); },
1445
+ reviewMemory: function (id, decision) {
1446
+ if (decision === 'prune' && state.armed !== 'prune:' + id) { state.armed = 'prune:' + id; render(); setTimeout(function () { if (state.armed === 'prune:' + id) { state.armed = null; render(); } }, 6000); return; }
1447
+ state.armed = null; state.memoryReviewBusy = id; render();
1448
+ // Restore is Accept on the wire: the bridge returns a quarantined memory to recall before marking it accepted.
1449
+ call('memory.review', { id: id, decision: decision === 'restore' ? 'accept' : decision }).then(function (d) {
1450
+ state.memoryReviewBusy = null;
1451
+ var row = d.decision || {};
1452
+ state.decisions = state.decisions || {}; state.decisions[id] = row.decision || (decision === 'prune' ? 'pruned' : 'accepted');
1453
+ state.decisionTs = state.decisionTs || {}; state.decisionTs[id] = new Date().toISOString();
1454
+ if (decision === 'prune') { state.memories = (state.memories || []).filter(function (m) { return m.id !== id; }); if (state.selectedMemory === id) state.selectedMemory = null; }
1455
+ toast(decision === 'prune' ? 'Memory quarantined.' : decision === 'restore' ? 'Memory restored to recall.' : 'Memory accepted.'); render();
1456
+ }, function (e) { state.memoryReviewBusy = null; toast(e.message); render(); });
1457
+ },
1458
+ pruneAllReady: function () {
1459
+ if (state.bulkPrune) return;
1460
+ var ids = (state.review || []).filter(function (e) { return isPruneReady(e) && !latestDecisionFor(e); }).map(function (e) { return e.lesson_id; });
1461
+ if (!ids.length) return;
1462
+ if (state.armed !== 'prune-ready') { state.armed = 'prune-ready'; render(); setTimeout(function () { if (state.armed === 'prune-ready') { state.armed = null; render(); } }, 6000); return; }
1463
+ state.armed = null; state.bulkPrune = { done: 0, total: ids.length }; render();
1464
+ // One memory at a time through the same review call as the card's Prune; the first failure stops the run.
1465
+ ids.reduce(function (chain, id) {
1466
+ return chain.then(function () {
1467
+ return call('memory.review', { id: id, decision: 'prune', note: 'prune ready' }).then(function (d) {
1468
+ var row = d.decision || {};
1469
+ state.decisions = state.decisions || {}; state.decisions[id] = row.decision || 'pruned';
1470
+ state.decisionTs = state.decisionTs || {}; state.decisionTs[id] = new Date().toISOString();
1471
+ state.bulkPrune.done += 1; render();
1472
+ });
1473
+ });
1474
+ }, Promise.resolve()).then(function () {
1475
+ var n = state.bulkPrune.done; state.bulkPrune = null;
1476
+ toast('Quarantined ' + fmt(n) + '. Restore any of them from its card.'); render();
1477
+ }, function (e) {
1478
+ var b = state.bulkPrune; state.bulkPrune = null;
1479
+ toast('Stopped after ' + fmt(b.done) + ' of ' + fmt(b.total) + ': ' + e.message); render();
1480
+ });
1481
+ },
1482
+ guardrailsLoad: function () { loadGuardrails(); },
1483
+ guardrailsSave: function () {
1484
+ var f = function (id) { var el = document.querySelector('#' + id); return el ? el.value : null; };
1485
+ var patch = { min_words: Number(f('grMinWords')), min_distinct_chars: Number(f('grDistinct')), max_repeat_ratio: Number(f('grRepeat')),
1486
+ banned_patterns: String(f('grBanned') || '').split('\n').map(function (s) { return s.trim(); }).filter(Boolean),
1487
+ llm_review: { enabled: !!(document.querySelector('#grLlm') || {}).checked, model: f('grModel') || 'haiku', max_per_run: Number(f('grMax')) || 20 } };
1488
+ if(state.guardrails && state.guardrails.llm_review && state.guardrails.llm_review.supported===false)delete patch.llm_review;
1489
+ state.guardrailsBusy = 'save'; renderModalIfOpen();
1490
+ call('memory.guardrails.set', { patch: JSON.stringify(patch) }).then(function (d) { state.guardrails = d.guardrails; state.guardrailsBusy = null; toast('Guardrails saved.'); renderModalIfOpen(); }, function (e) { state.guardrailsBusy = null; toast(e.message); renderModalIfOpen(); });
1491
+ },
1492
+ guardrailsRun: function (apply) {
1493
+ state.guardrailsBusy = apply ? 'apply' : 'run'; renderModalIfOpen();
1494
+ call('memory.guardrails.run', { days: 30, apply: apply === true }).then(function (d) {
1495
+ state.guardrailsRun = d; state.guardrailsBusy = null;
1496
+ if (apply) { var ids = (d.verdicts || []).filter(function (v) { return v.applied; }).map(function (v) { return v.id; }); state.memories = (state.memories || []).filter(function (m) { return ids.indexOf(m.id) === -1; }); state.decisions = state.decisions || {}; ids.forEach(function (id) { state.decisions[id] = 'pruned'; }); toast('Quarantined ' + fmt(d.run.pruned) + '.'); }
1497
+ else toast(fmt(d.run.flagged) + ' flagged, ' + fmt(d.run.kept) + ' kept' + (d.run.review ? ', ' + fmt(d.run.review) + ' for you' : '') + '.');
1498
+ renderModalIfOpen(); render();
1499
+ }, function (e) { state.guardrailsBusy = null; toast(e.message); renderModalIfOpen(); });
1500
+ },
1501
+ chooseExtraction: function (tier) { setupAction('extraction', 'graph.setup.extraction', { tier: tier }, function (d) { toast('Indexing set to ' + ((d.extraction || {}).label || tier) + '.'); }); },
1502
+ chooseEmbedding: function (provider) { setupAction('embedding', 'graph.setup.embedding', { provider: provider }, function (d) { toast('Embeddings set to ' + ((d.embedding || {}).label || provider) + '.'); }); },
1503
+ answerLocalOnly: function (localOnly) { setupAction('preference', 'graph.setup.embedding', { local_only: localOnly === true }, function (d) { var rec = (d.embedding || {}).recommended; toast(rec ? 'Recommended: ' + rec.label + '.' : 'Preference saved.'); }); },
1504
+ fetchEmbedding: function (provider) { setupAction('fetch', 'graph.setup.embedding', { provider: provider, fetch: true }, function (d) { var f = d.fetch || {}; toast(f.started ? 'Fetching the model in the background.' : f.already_running ? 'A fetch is already running.' : 'Nothing to fetch.'); watchFetch(); }); },
1505
+ pickFolder: function () {
1506
+ call('pick.folder').then(function (d) {
1507
+ if (!d || !d.path) return;
1508
+ setupAction('add', 'graph.setup.sources', { action: 'add', path: d.path }, function () { toast('Folder added.'); });
1509
+ }, function (e) { if (e.message !== 'No folder chosen.') toast(e.message); });
1510
+ },
1511
+ sourceToggle: function (path, enabled) { setupAction('toggle', 'graph.setup.sources', { action: enabled ? 'enable' : 'disable', path: path }); },
1512
+ sourceRemove: function (path) { setupAction('remove', 'graph.setup.sources', { action: 'remove', path: path }, function () { toast('Folder removed.'); }); },
1513
+ claimOwner: function () {
1514
+ if (state.armed !== 'owner') { state.armed = 'owner'; render(); setTimeout(function () { if (state.armed === 'owner') { state.armed = null; render(); } }, 6000); return; }
1515
+ setupAction('owner', 'graph.setup.owner', {}, function () { toast('This Mac is now the ingestion owner.'); });
1516
+ },
1517
+ indexSample: function () {
1518
+ setupAction('sample', 'graph.sample', { limit: 3 }, function (d) {
1519
+ var q = (d.queued || []).length;
1520
+ if (d.started) { state.ingesting = { pid: d.pid, note: 'Indexing ' + fmt(q) + ' sample document' + (q === 1 ? '' : 's') + ' (pid ' + esc(d.pid) + ')' }; watchIngest(); }
1521
+ else if (d.already_running) { toast((q ? fmt(q) + ' queued. ' : '') + 'Indexing is already running; this page refreshes as it runs.'); watchIngest(); }
1522
+ else if (d.nothing_pending) toast('Nothing new to index in the folders that are on.');
1523
+ else if (d.budget_exhausted) toast('Today\'s LightRAG budget is used up. It resets tomorrow.');
1524
+ if ((d.skipped || []).length) toast(fmt(d.skipped.length) + ' skipped: ' + d.skipped.map(function (x) { return x.reason; }).join(', '));
1525
+ });
1526
+ },
1527
+ askGraph: function () {
1528
+ if (state.askBusy) return;
1529
+ var input = document.querySelector('#askQ'); var q = input ? input.value.trim() : state.askQ;
1530
+ if (q.length < 3) { toast('Ask a fuller question.'); return; }
1531
+ state.askQ = q; state.askBusy = true; state.askAnswer = null; state.askStartedAt = Date.now(); render(); startAskTick();
1532
+ call('graph.ask', { q: q }).then(function (d) { state.askBusy = false; state.askStartedAt = null; stopAskTick(); state.askAnswer = d; render(); }, function (e) { state.askBusy = false; state.askStartedAt = null; stopAskTick(); toast(e.message); render(); });
1533
+ },
1534
+ scheduleSet: function (enabled) {
1535
+ var sel = document.querySelector('#scheduleInterval'); var interval = sel ? Number(sel.value) || 3600 : 3600;
1536
+ setupAction('schedule', 'graph.schedule', { enabled: enabled, intervalS: interval }, function (d) { toast(d.installed ? 'Scheduled batches on.' : 'Scheduled batches off.'); });
1537
+ },
1538
+ startIngest: function () {
1539
+ var sel = document.querySelector('#ingestLimit'); var limit = sel ? (Number(sel.value) || 10) : 10; state.ingestLimit = limit;
1540
+ state.ingesting = { note: 'Starting…' }; render();
1541
+ call('graph.ingest', { limit: limit }).then(function (d) {
1542
+ if (d.started) { var batch = Math.min(d.limit || 0, d.pending || 0); state.ingesting = { pid: d.pid, limit: d.limit, startedAt: Date.now(), note: (d.pending <= batch ? 'Indexing all ' + fmt(d.pending) + ' queued' : 'Indexing the next ' + fmt(batch) + ' of ' + fmt(d.pending) + ' queued') + ' (pid ' + esc(d.pid) + ')' }; watchIngest(); }
1543
+ else if (d.already_running) { state.ingesting = null; toast('Indexing is already running' + (d.lock && d.lock.owner_pid ? ' (pid ' + d.lock.owner_pid + ')' : '') + '. This card refreshes as it runs.'); watchIngest(); }
1544
+ else if (d.nothing_pending) { state.ingesting = null; toast('Nothing is queued.'); }
1545
+ else if (d.budget_exhausted) { state.ingesting = null; toast('Today\'s LightRAG budget is used up' + (d.budget ? ' (' + fmt(d.budget.used) + ' of ' + fmt(d.budget.cap) + ' calls)' : '') + '. It resets tomorrow.'); }
1546
+ else { state.ingesting = null; toast('Nothing started.'); }
1547
+ render();
1548
+ }, function (e) { state.ingesting = null; toast(e.message); render(); });
1549
+ },
1550
+ buildIndex: function () { state.building = 'Starting…'; render(); call('graph.build').then(function (d) { state.building = d.already_running ? 'A build is already running' : 'Building…'; render(); var tries = 0; var poll = setInterval(function () { tries++; loadGraphStatus().then(function () { var b = state.graphStatus && state.graphStatus.build; if (b && (b.state === 'done' || b.state === 'failed')) { state.building = b.state === 'done' ? 'Index rebuilt' : ('Build failed: ' + (b.error || '')); clearInterval(poll); render(); } else if (tries > 150) { state.building = 'Still building after 5 minutes. Refresh later.'; clearInterval(poll); render(); } }); }, 2000); }, function (e) { state.building = null; toast(e.message); render(); }); },
1551
+ decide: decide, openMemoryRecord: function (id) { state.view = 'learning'; state.filter = 'memories'; state.selectedMemory = id; state.memoryQuery = ''; state.memoryHits = null; if (!state.memories.some(function (m) { return m.id === id; })) state.memories.unshift({ id: id, summary: 'Memory ' + id }); render(); },
1552
+ reveal: function (id) { call('memory.reveal', { id: id }).then(function () {}, function (e) { toast(e.message); }); },
1553
+ copyMemory: copyMemory, openCopyContext: openCopyContext, copyOption: copyOption, copyNow: copyNow, selectContextText: selectContextText,
1554
+ closeModal: closeModal, openSettings: openSettings, openLog: openLog
1555
+ };
1556
+ window.openSettings = openSettings;
1557
+ document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && document.querySelector('.modal')) closeModal(); });
1558
+
1559
+ render();
1560
+ loadAll();
1561
+ })();