greprag 5.76.0 → 5.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/app.js ADDED
@@ -0,0 +1,360 @@
1
+ const state = { model: null, route: 'home', projectId: null, loading: true };
2
+
3
+ const content = document.querySelector('#app-content');
4
+ const title = document.querySelector('#page-title');
5
+ const eyebrow = document.querySelector('#page-eyebrow');
6
+ const picker = document.querySelector('#project-picker');
7
+ const nav = document.querySelector('#nav-list');
8
+ const sidebarStatus = document.querySelector('#sidebar-status');
9
+ const refreshButton = document.querySelector('#refresh-button');
10
+ const toast = document.querySelector('#toast');
11
+
12
+ const pageTitles = {
13
+ home: ['Your GrepRAG', 'LOCAL CONTROL PLANE'],
14
+ memory: ['Memory', 'WHAT YOUR AGENTS REMEMBER'],
15
+ skills: ['Skills', 'CAPABILITIES ACROSS AGENTS'],
16
+ knowledge: ['Knowledge', 'YOUR SEARCHABLE CORPUS'],
17
+ agents: ['Agents', 'CONNECTED HARNESSES'],
18
+ notifications: ['Notifications', 'WHAT GREPRAG TELLS YOUR AGENTS'],
19
+ privacy: ['Privacy & data', 'STORAGE AND CONTROL'],
20
+ advanced: ['Advanced', 'LOCAL RUNTIME'],
21
+ };
22
+
23
+ function escapeHtml(value) {
24
+ return String(value ?? '')
25
+ .replaceAll('&', '&')
26
+ .replaceAll('<', '&lt;')
27
+ .replaceAll('>', '&gt;')
28
+ .replaceAll('"', '&quot;')
29
+ .replaceAll("'", '&#039;');
30
+ }
31
+
32
+ function formatNumber(value) {
33
+ return new Intl.NumberFormat(undefined, { notation: value >= 100000 ? 'compact' : 'standard', maximumFractionDigits: 1 }).format(value || 0);
34
+ }
35
+
36
+ function formatDate(value) {
37
+ if (!value) return 'Not scheduled';
38
+ const date = new Date(value);
39
+ if (Number.isNaN(date.getTime())) return 'Unknown';
40
+ return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' }).format(date);
41
+ }
42
+
43
+ function statusPill(label, status = 'healthy') {
44
+ return `<span class="status-pill ${escapeHtml(status)}">${escapeHtml(label)}</span>`;
45
+ }
46
+
47
+ function miniPill(label, status = 'healthy') {
48
+ return `<span class="mini-pill ${escapeHtml(status)}">${escapeHtml(label)}</span>`;
49
+ }
50
+
51
+ function sectionHeading(kicker, heading, copy = '') {
52
+ return `<div class="section-heading"><div><span class="section-kicker">${escapeHtml(kicker)}</span><h2>${escapeHtml(heading)}</h2></div>${copy ? `<p>${escapeHtml(copy)}</p>` : ''}</div>`;
53
+ }
54
+
55
+ function toggle(setting, enabled, label, kind = 'setting') {
56
+ const attr = kind === 'setting' ? `data-setting="${escapeHtml(setting)}"`
57
+ : kind === 'interrupt' ? `data-interrupt="${escapeHtml(setting)}"`
58
+ : `data-adapter="${escapeHtml(setting)}"`;
59
+ return `<button type="button" class="toggle" role="switch" aria-label="${escapeHtml(label)}" aria-checked="${enabled ? 'true' : 'false'}" ${attr}></button>`;
60
+ }
61
+
62
+ function settingRow({ setting, title: rowTitle, description, enabled, scope = 'This project', kind = 'setting' }) {
63
+ return `<div class="setting-row"><div class="setting-copy"><div class="setting-title">${escapeHtml(rowTitle)}<span class="setting-scope">${escapeHtml(scope)}</span></div><p>${escapeHtml(description)}</p></div>${toggle(setting, enabled, rowTitle, kind)}</div>`;
64
+ }
65
+
66
+ function metric(label, value, note) {
67
+ return `<div class="metric"><span class="metric-label">${escapeHtml(label)}</span><div class="metric-value">${escapeHtml(value)}</div><div class="metric-note">${escapeHtml(note)}</div></div>`;
68
+ }
69
+
70
+ function signalRail(model) {
71
+ const live = {
72
+ agent: model.agents.some(agent => agent.configured),
73
+ capture: model.memory.captureEnabled,
74
+ memory: model.account.connected,
75
+ recall: model.memory.recapEnabled,
76
+ };
77
+ return `<div class="signal-rail" aria-label="GrepRAG memory path">
78
+ ${[['agent', 'AG', 'Agents'], ['capture', 'CP', 'Capture'], ['memory', 'MM', 'Memory'], ['recall', 'RC', 'Recall']].map(([id, glyph, label]) =>
79
+ `<div class="signal-node ${live[id] ? 'is-live' : ''}"><span class="node-core">${glyph}</span><span>${label}</span></div>`).join('')}
80
+ </div>`;
81
+ }
82
+
83
+ function renderHome(model) {
84
+ const project = model.selectedProject;
85
+ if (!project) return renderNoProject();
86
+ const healthy = model.health.state === 'healthy';
87
+ const issues = model.health.issues.length
88
+ ? `<div class="attention-panel">${model.health.issues.map(issue => `<div class="attention-row">${escapeHtml(issue)}</div>`).join('')}</div>`
89
+ : '';
90
+ const connected = model.agents.filter(agent => agent.configured).length;
91
+ return `<div class="page-stack">
92
+ <section class="signal-hero">
93
+ <div class="hero-copy">
94
+ ${statusPill(healthy ? 'Everything is working' : 'Needs attention', healthy ? 'healthy' : 'attention')}
95
+ <h2>Your agents remember ${escapeHtml(project.name)}.</h2>
96
+ <p>GrepRAG captures the work, keeps your knowledge searchable, and returns the right context when an agent needs it.</p>
97
+ </div>
98
+ ${signalRail(model)}
99
+ </section>
100
+ ${issues}
101
+ <section class="metric-grid" aria-label="GrepRAG totals">
102
+ ${metric('Memory items', formatNumber(model.memory.nodes), model.memory.captureEnabled ? 'Capture is on for this project' : 'Capture is paused for this project')}
103
+ ${metric('Knowledge sources', formatNumber(model.corpus.count), model.corpus.failures ? `${model.corpus.failures} refresh failures` : 'Sources are current')}
104
+ ${metric('Available skills', formatNumber(model.skills.mirrored || model.skills.installedUnique), `${model.skills.installedUnique} installed locally`)}
105
+ ${metric('Connected agents', `${connected}/${model.agents.length}`, connected ? 'Receiving GrepRAG context' : 'Finish agent setup')}
106
+ </section>
107
+ ${sectionHeading('CURRENT PROJECT', 'Core settings', 'The controls most likely to change how GrepRAG feels day to day.')}
108
+ <section class="settings-list">
109
+ ${settingRow({ setting: 'memoryCapture', title: 'Remember agent conversations', description: 'Save completed conversations so they can be summarized and recalled later.', enabled: project.settings.memoryCapture })}
110
+ ${settingRow({ setting: 'sessionStartRecap', title: 'Start sessions with recent context', description: 'Give new sessions a compact recap of recent work in this project.', enabled: project.settings.sessionStartRecap })}
111
+ </section>
112
+ </div>`;
113
+ }
114
+
115
+ function renderMemory(model) {
116
+ const settings = model.selectedProject?.settings;
117
+ if (!settings) return renderNoProject();
118
+ return `<div class="page-stack">
119
+ <section class="signal-hero">
120
+ <div class="hero-copy"><span class="section-kicker">MEMORY PATH</span><h2>Keep context without carrying the whole conversation.</h2><p>Raw turns become compact project history. You control capture, startup recall, and the local safety net around long sessions.</p></div>
121
+ ${signalRail(model)}
122
+ </section>
123
+ ${sectionHeading('CAPTURE & RECALL', 'Project memory', 'These settings affect only the selected project and take effect on the next hook event.')}
124
+ <section class="settings-list">
125
+ ${settingRow({ setting: 'memoryCapture', title: 'Remember agent conversations', description: 'Capture completed turns for later search and summaries.', enabled: settings.memoryCapture })}
126
+ ${settingRow({ setting: 'sessionStartRecap', title: 'Start sessions with recent context', description: 'Add a concise recent-work recap when an agent session starts.', enabled: settings.sessionStartRecap })}
127
+ ${settingRow({ setting: 'contextGovernor', title: 'Track long-session context', description: 'Measure context size locally so long sessions can be compacted before they become wasteful.', enabled: settings.contextGovernor })}
128
+ ${settingRow({ setting: 'pngArchive', title: 'Archive before compaction', description: 'Keep a lossless local visual archive before a long conversation is compressed.', enabled: settings.pngArchive })}
129
+ </section>
130
+ <section class="metric-grid">
131
+ ${metric('Stored memory', formatNumber(model.memory.nodes), `${model.memory.stores} project memory ${model.memory.stores === 1 ? 'store' : 'stores'}`)}
132
+ ${metric('Capture', settings.memoryCapture ? 'On' : 'Off', 'Changes apply on the next completed turn')}
133
+ ${metric('Session recap', settings.sessionStartRecap ? 'On' : 'Off', 'Recent context at session start')}
134
+ ${metric('Context threshold', formatNumber(settings.contextThreshold), 'Tokens before the local advisory threshold')}
135
+ </section>
136
+ </div>`;
137
+ }
138
+
139
+ function agentCard(agent, projectId, skillsPage = false) {
140
+ const stateLabel = agent.configured ? 'Connected' : agent.installed ? 'Needs attention' : 'Not installed';
141
+ const stateKind = agent.configured ? 'healthy' : 'attention';
142
+ const adapterOn = agent.managedSkillCount > 0;
143
+ return `<article class="agent-card">
144
+ <div class="agent-head"><div class="agent-name"><span class="agent-glyph">${escapeHtml(agent.label.slice(0, 2).toUpperCase())}</span>${escapeHtml(agent.label)}</div>${miniPill(stateLabel, stateKind)}</div>
145
+ <div class="agent-meta"><span>${agent.skillCount} local skills</span><span>${agent.managedSkillCount} GrepRAG adapters</span></div>
146
+ <p class="agent-note">${escapeHtml(agent.note)}</p>
147
+ ${skillsPage && agent.canToggleSkillAdapter ? `<div class="agent-actions"><span class="setting-copy"><span class="setting-title">GrepRAG skill adapters</span></span>${toggle(agent.id, adapterOn, `${agent.label} skill adapters`, 'adapter')}</div>` : ''}
148
+ </article>`;
149
+ }
150
+
151
+ function renderSkills(model) {
152
+ return `<div class="page-stack">
153
+ ${sectionHeading('SKILL LAYER', 'Capabilities your agents can load', 'GrepRAG keeps one canonical skill library and can install lightweight native adapters into supported agents.')}
154
+ <section class="metric-grid">
155
+ ${metric('Mirrored skills', formatNumber(model.skills.mirrored), 'Canonical skills in your GrepRAG account')}
156
+ ${metric('Installed locally', formatNumber(model.skills.installedUnique), 'Unique skills found across agents')}
157
+ ${metric('Adapter targets', formatNumber(model.skills.adaptersActive), 'Agents with GrepRAG-managed adapters')}
158
+ ${metric('Sync health', model.account.cloudStatus === 'connected' ? 'Ready' : 'Offline', 'Existing local adapters remain available offline')}
159
+ </section>
160
+ ${sectionHeading('ADAPTERS', 'Choose where skills appear', 'Turning an adapter off removes only GrepRAG-managed adapter files. Your own local skills are preserved.')}
161
+ <section class="agent-grid">${model.agents.map(agent => agentCard(agent, model.selectedProject?.id, true)).join('')}</section>
162
+ </div>`;
163
+ }
164
+
165
+ function renderKnowledge(model) {
166
+ const stores = model.corpus.stores;
167
+ const list = stores.length ? `<div class="knowledge-list" role="table" aria-label="Knowledge sources">${stores.map(store =>
168
+ `<div class="knowledge-row" role="row"><div class="knowledge-name" role="cell"><strong title="${escapeHtml(store.name)}">${escapeHtml(store.name)}</strong><span>${escapeHtml(store.kind)}${store.tags.length ? ` · #${escapeHtml(store.tags.join(' #'))}` : ''}</span></div><div role="cell"><span class="table-value">${formatNumber(store.nodes)}</span><span class="table-meta">nodes</span></div><div role="cell"><span class="table-value">${escapeHtml(store.policy)}</span><span class="table-meta">refresh</span></div><div role="cell">${miniPill(store.failures ? `${store.failures} failed` : 'Healthy', store.health)}<span class="table-meta">checked ${formatDate(store.lastCheckedAt)}</span></div></div>`).join('')}</div>`
169
+ : `<div class="empty-card"><strong>No knowledge sources yet</strong><p>Upload a reference with <code>greprag corpus upload &lt;file-or-url&gt; --raw</code>. It will appear here automatically.</p></div>`;
170
+ return `<div class="page-stack">
171
+ ${sectionHeading('KNOWLEDGE', 'Searchable reference collections', 'Documents, codebases, transcripts, and other sources your agents can search before answering.')}
172
+ <section class="metric-grid">
173
+ ${metric('Sources', formatNumber(model.corpus.count), model.corpus.status === 'connected' ? 'Connected to GrepRAG' : 'Cloud data unavailable')}
174
+ ${metric('Indexed nodes', formatNumber(model.corpus.nodes), 'Searchable sections across all sources')}
175
+ ${metric('Words', formatNumber(model.corpus.words), 'Approximate indexed content')}
176
+ ${metric('Refresh health', model.corpus.failures ? `${model.corpus.failures} failed` : 'Healthy', model.corpus.failures ? 'Open the affected source for details' : 'No source refresh failures')}
177
+ </section>
178
+ ${sectionHeading('SOURCES', 'Corpus inventory', 'Freshness is based on each source’s real refresh state, not its original upload date.')}
179
+ ${list}
180
+ </div>`;
181
+ }
182
+
183
+ function renderAgents(model) {
184
+ return `<div class="page-stack">
185
+ ${sectionHeading('AGENTS', 'Connected to this machine', 'Connection health comes from the hooks, plugins, and skills installed locally—not from an account checkbox.')}
186
+ <section class="agent-grid">${model.agents.map(agent => agentCard(agent, model.selectedProject?.id)).join('')}</section>
187
+ <section class="signal-hero"><div class="hero-copy"><span class="section-kicker">ONE MEMORY</span><h2>Different agents. The same project context.</h2><p>Each integration speaks its agent’s native hook language while sharing the same project identity, memory, skills, and knowledge.</p></div>${signalRail(model)}</section>
188
+ </div>`;
189
+ }
190
+
191
+ function renderNotifications(model) {
192
+ const settings = model.selectedProject?.settings;
193
+ const groups = Map.groupBy ? Map.groupBy(model.notifications.modules, module => module.group) : model.notifications.modules.reduce((map, module) => map.set(module.group, [...(map.get(module.group) || []), module]), new Map());
194
+ const guidance = [...groups.entries()].map(([group, modules]) => `<section class="guidance-group"><h3>${escapeHtml(group)}</h3>${modules.map(module => settingRow({ setting: module.id, title: module.label, description: module.description, enabled: module.enabled, scope: module.surface, kind: 'interrupt' })).join('')}</section>`).join('');
195
+ return `<div class="page-stack">
196
+ ${sectionHeading('INBOX', 'When to surface messages', 'Message delivery and agent guidance are separate controls.')}
197
+ <section class="settings-list">
198
+ <div class="setting-row"><div class="setting-copy"><div class="setting-title">Inbox notifications<span class="setting-scope">This project</span></div><p>Choose when unread GrepRAG messages should appear in agent context.</p></div><select class="inline-select" data-select-setting="inboxNotify" aria-label="Inbox notification frequency"><option value="every_turn" ${settings?.inboxNotify === 'every_turn' ? 'selected' : ''}>Every turn</option><option value="session_start_only" ${settings?.inboxNotify === 'session_start_only' ? 'selected' : ''}>Session start only</option><option value="off" ${settings?.inboxNotify === 'off' ? 'selected' : ''}>Off</option></select></div>
199
+ ${settingRow({ setting: 'emailAutosave', title: 'Save email attachments automatically', description: 'Download new agent-email attachments into this project when messages arrive.', enabled: settings?.emailAutosave || false })}
200
+ </section>
201
+ ${sectionHeading('AGENT GUIDANCE', 'Control each GrepRAG prompt', 'These tenant-wide switches change the actual startup and just-in-time guidance delivered to every connected agent.')}
202
+ <div class="guidance-groups">${guidance}</div>
203
+ </div>`;
204
+ }
205
+
206
+ function renderPrivacy(model) {
207
+ const settings = model.selectedProject?.settings;
208
+ return `<div class="page-stack">
209
+ ${sectionHeading('DATA BOUNDARY', 'Where your GrepRAG data lives', 'The app runs locally. It reads local integration state and uses your CLI credential to request account metrics without exposing the key to browser JavaScript.')}
210
+ <section class="privacy-grid">
211
+ <article class="privacy-card"><h3>Local machine</h3><p>Hooks, adapter files, project settings, archives, and compressed-output originals stay on this machine.</p></article>
212
+ <article class="privacy-card"><h3>GrepRAG account</h3><p>Captured project memory, corpus sources, mirrored skills, and cross-agent messages are stored in your tenant.</p></article>
213
+ <article class="privacy-card"><h3>Browser session</h3><p>This page is served on loopback with a short-lived local session. Your GrepRAG API key never enters the page.</p></article>
214
+ </section>
215
+ ${sectionHeading('CAPTURE', 'Project data controls', 'Stopping capture does not delete existing memory. It prevents future completed turns from being stored.')}
216
+ <section class="settings-list">${settingRow({ setting: 'memoryCapture', title: 'Remember agent conversations', description: 'Allow completed turns from this project to be stored in your GrepRAG account.', enabled: settings?.memoryCapture || false })}</section>
217
+ </div>`;
218
+ }
219
+
220
+ function renderAdvanced(model) {
221
+ const settings = model.selectedProject?.settings;
222
+ return `<div class="page-stack">
223
+ ${sectionHeading('LOCAL RUNTIME', 'Advanced project features', 'These are real hook behaviors. Changes apply without reinstalling GrepRAG.')}
224
+ <section class="settings-list">
225
+ ${settingRow({ setting: 'crushWrap', title: 'Compress noisy tool output', description: 'Reduce test, build, and search output before it enters agent context while keeping originals recoverable.', enabled: settings?.crushWrap || false })}
226
+ ${settingRow({ setting: 'contextGovernor', title: 'Track context pressure', description: 'Measure long-session context locally for a pull-only health reading.', enabled: settings?.contextGovernor || false })}
227
+ ${settingRow({ setting: 'pngArchive', title: 'Archive before compaction', description: 'Render a lossless local archive before supported agents compact long transcripts.', enabled: settings?.pngArchive || false })}
228
+ </section>
229
+ ${sectionHeading('AUTOMATIC REPAIRS', 'Mechanic', 'The global panic switch remains CLI-controlled so it can work even when the app or network is unavailable.')}
230
+ <section class="setting-row"><div class="setting-copy"><div class="setting-title">Repair runtime</div><p>Use <code>greprag mechanic off</code> for the local-first panic switch and <code>greprag mechanic on</code> to resume.</p></div>${miniPill(model.advanced.mechanicEnabled ? 'Running' : 'Paused', model.advanced.mechanicEnabled ? 'healthy' : 'attention')}</section>
231
+ ${sectionHeading('PATHS', 'Diagnostic locations')}
232
+ <section class="path-grid">
233
+ <div class="path-row"><span>Project settings</span><code class="path-value">${escapeHtml(model.advanced.projectSettingsPath)}</code></div>
234
+ <div class="path-row"><span>Tenant settings</span><code class="path-value">${escapeHtml(model.advanced.settingsPath)}</code></div>
235
+ <div class="path-row"><span>GrepRAG API</span><code class="path-value">${escapeHtml(model.advanced.apiUrl)}</code></div>
236
+ </section>
237
+ </div>`;
238
+ }
239
+
240
+ function renderNoProject() {
241
+ return `<div class="empty-card"><strong>No local GrepRAG project found</strong><p>Run <code>greprag init</code> inside a Git repository, then reopen the app.</p></div>`;
242
+ }
243
+
244
+ function render() {
245
+ const model = state.model;
246
+ const [pageTitle, pageEyebrow] = pageTitles[state.route] || pageTitles.home;
247
+ title.textContent = pageTitle;
248
+ eyebrow.textContent = pageEyebrow;
249
+ nav.querySelectorAll('[data-route]').forEach(button => button.setAttribute('aria-current', button.dataset.route === state.route ? 'page' : 'false'));
250
+ if (!model) return;
251
+ const pages = { home: renderHome, memory: renderMemory, skills: renderSkills, knowledge: renderKnowledge, agents: renderAgents, notifications: renderNotifications, privacy: renderPrivacy, advanced: renderAdvanced };
252
+ content.innerHTML = (pages[state.route] || pages.home)(model);
253
+ content.setAttribute('aria-busy', 'false');
254
+ }
255
+
256
+ function renderChrome(model) {
257
+ picker.innerHTML = model.projects.map(project => `<option value="${escapeHtml(project.id)}" ${project.id === model.selectedProject?.id ? 'selected' : ''}>${escapeHtml(project.name)}</option>`).join('');
258
+ picker.disabled = model.projects.length < 2;
259
+ const cloudState = model.account.cloudStatus === 'connected' ? 'healthy' : 'attention';
260
+ sidebarStatus.innerHTML = `<div class="connection-line"><span class="status-dot ${cloudState}"></span>${model.account.connected ? escapeHtml(model.account.handle || 'Account connected') : 'Account not connected'}</div><div class="connection-line"><span class="status-dot ${model.health.state}"></span>${model.health.state === 'healthy' ? 'All systems healthy' : 'Attention needed'}</div><div class="sidebar-version">greprag ${escapeHtml(model.app.version)} · local app</div>`;
261
+ }
262
+
263
+ function showToast(message, error = false) {
264
+ toast.textContent = message;
265
+ toast.className = `toast show${error ? ' error' : ''}`;
266
+ clearTimeout(showToast.timer);
267
+ showToast.timer = setTimeout(() => { toast.className = 'toast'; }, 3200);
268
+ }
269
+
270
+ async function api(path, options = {}) {
271
+ const response = await fetch(path, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } });
272
+ const body = await response.json().catch(() => ({}));
273
+ if (!response.ok || body.ok === false) throw new Error(body.error || `Request failed (${response.status})`);
274
+ return body;
275
+ }
276
+
277
+ async function loadModel({ quiet = false } = {}) {
278
+ if (!quiet) {
279
+ content.setAttribute('aria-busy', 'true');
280
+ content.innerHTML = `<div class="loading-state"><span class="loading-pulse" aria-hidden="true"></span><p>Reading your GrepRAG setup…</p></div>`;
281
+ }
282
+ refreshButton.disabled = true;
283
+ try {
284
+ const query = state.projectId ? `?project=${encodeURIComponent(state.projectId)}` : '';
285
+ const body = await api(`/api/model${query}`);
286
+ state.model = body.model;
287
+ state.projectId = body.model.selectedProject?.id || null;
288
+ renderChrome(body.model);
289
+ render();
290
+ } catch (error) {
291
+ content.innerHTML = `<div class="fatal-state"><strong>GrepRAG could not load</strong><p>${escapeHtml(error.message)}</p><button type="button" class="action-button" data-retry>Try again</button></div>`;
292
+ showToast(error.message, true);
293
+ } finally {
294
+ refreshButton.disabled = false;
295
+ }
296
+ }
297
+
298
+ async function mutate(element, path, method, body, success) {
299
+ element.disabled = true;
300
+ try {
301
+ await api(path, { method, body: JSON.stringify(body) });
302
+ await loadModel({ quiet: true });
303
+ showToast(success);
304
+ } catch (error) {
305
+ element.disabled = false;
306
+ showToast(error.message, true);
307
+ }
308
+ }
309
+
310
+ nav.addEventListener('click', event => {
311
+ const button = event.target.closest('[data-route]');
312
+ if (!button) return;
313
+ state.route = button.dataset.route;
314
+ history.replaceState(null, '', `#${state.route}`);
315
+ render();
316
+ document.querySelector('#content').focus({ preventScroll: true });
317
+ });
318
+
319
+ picker.addEventListener('change', () => {
320
+ state.projectId = picker.value;
321
+ loadModel();
322
+ });
323
+
324
+ refreshButton.addEventListener('click', () => loadModel());
325
+
326
+ content.addEventListener('click', event => {
327
+ const retry = event.target.closest('[data-retry]');
328
+ if (retry) return loadModel();
329
+ const setting = event.target.closest('[data-setting]');
330
+ if (setting) {
331
+ const key = setting.dataset.setting;
332
+ const enabled = setting.getAttribute('aria-checked') !== 'true';
333
+ return mutate(setting, '/api/project-settings', 'PATCH', { projectId: state.projectId, patch: { [key]: enabled } }, `${setting.getAttribute('aria-label')} ${enabled ? 'enabled' : 'disabled'}.`);
334
+ }
335
+ const interrupt = event.target.closest('[data-interrupt]');
336
+ if (interrupt) {
337
+ const enabled = interrupt.getAttribute('aria-checked') !== 'true';
338
+ return mutate(interrupt, '/api/interrupts', 'PATCH', { id: interrupt.dataset.interrupt, enabled }, `${interrupt.getAttribute('aria-label')} ${enabled ? 'enabled' : 'disabled'} across agents.`);
339
+ }
340
+ const adapter = event.target.closest('[data-adapter]');
341
+ if (adapter) {
342
+ const enabled = adapter.getAttribute('aria-checked') !== 'true';
343
+ return mutate(adapter, '/api/skill-adapter', 'POST', { platform: adapter.dataset.adapter, projectId: state.projectId, enabled }, `${adapter.getAttribute('aria-label')} ${enabled ? 'enabled' : 'disabled'}.`);
344
+ }
345
+ });
346
+
347
+ content.addEventListener('change', event => {
348
+ const select = event.target.closest('[data-select-setting]');
349
+ if (!select) return;
350
+ mutate(select, '/api/project-settings', 'PATCH', { projectId: state.projectId, patch: { [select.dataset.selectSetting]: select.value } }, 'Inbox notifications updated.');
351
+ });
352
+
353
+ window.addEventListener('hashchange', () => {
354
+ const route = location.hash.slice(1);
355
+ if (pageTitles[route]) { state.route = route; render(); }
356
+ });
357
+
358
+ setInterval(() => api('/api/heartbeat', { method: 'POST', body: '{}' }).catch(() => {}), 20_000);
359
+ state.route = pageTitles[location.hash.slice(1)] ? location.hash.slice(1) : 'home';
360
+ loadModel();
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <rect width="32" height="32" rx="7" fill="#12121a"/>
3
+ <text x="4" y="23" font-family="monospace" font-size="16" font-weight="700" fill="#8178ff">gR</text>
4
+ </svg>
package/app/index.html ADDED
@@ -0,0 +1,54 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="dark">
7
+ <meta name="theme-color" content="#0a0a0f">
8
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
9
+ <link rel="stylesheet" href="/app.css">
10
+ <title>GrepRAG</title>
11
+ </head>
12
+ <body>
13
+ <a class="skip-link" href="#content">Skip to content</a>
14
+ <div class="app-shell">
15
+ <aside class="sidebar" aria-label="GrepRAG navigation">
16
+ <div class="brand-row">
17
+ <span class="brand-mark" aria-hidden="true">gR</span>
18
+ <span class="brand-name">GrepRAG</span>
19
+ <span class="local-badge">LOCAL</span>
20
+ </div>
21
+ <label class="project-picker-label" for="project-picker">Current project</label>
22
+ <select id="project-picker" class="project-picker" aria-label="Current project"></select>
23
+ <nav class="nav-list" id="nav-list">
24
+ <button type="button" data-route="home"><span>01</span>Home</button>
25
+ <button type="button" data-route="memory"><span>02</span>Memory</button>
26
+ <button type="button" data-route="skills"><span>03</span>Skills</button>
27
+ <button type="button" data-route="knowledge"><span>04</span>Knowledge</button>
28
+ <button type="button" data-route="agents"><span>05</span>Agents</button>
29
+ <button type="button" data-route="notifications"><span>06</span>Notifications</button>
30
+ <button type="button" data-route="privacy"><span>07</span>Privacy & data</button>
31
+ <button type="button" data-route="advanced"><span>08</span>Advanced</button>
32
+ </nav>
33
+ <div class="sidebar-status" id="sidebar-status" aria-live="polite"></div>
34
+ </aside>
35
+ <main class="main-panel" id="content" tabindex="-1">
36
+ <header class="topbar">
37
+ <div>
38
+ <p class="eyebrow" id="page-eyebrow">LOCAL CONTROL PLANE</p>
39
+ <h1 id="page-title">Loading GrepRAG</h1>
40
+ </div>
41
+ <button class="refresh-button" id="refresh-button" type="button">Refresh</button>
42
+ </header>
43
+ <div class="content" id="app-content" aria-live="polite" aria-busy="true">
44
+ <div class="loading-state">
45
+ <span class="loading-pulse" aria-hidden="true"></span>
46
+ <p>Reading your GrepRAG setup…</p>
47
+ </div>
48
+ </div>
49
+ </main>
50
+ </div>
51
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
52
+ <script src="/app.js" defer></script>
53
+ </body>
54
+ </html>
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ /** Local customer settings shared by the GrepRAG app and hook runtimes.
3
+ *
4
+ * Project behavior stays in .greprag/project.json, where the hooks already
5
+ * read it. Tenant-wide Interrupt preferences live in ~/.greprag/settings.json.
6
+ * The local app is only another reader/writer of those canonical surfaces.
7
+ *
8
+ * adr: adr/local-app-control-plane.md
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.localAppSettingsPath = localAppSettingsPath;
45
+ exports.readLocalAppSettings = readLocalAppSettings;
46
+ exports.setInterruptEnabled = setInterruptEnabled;
47
+ exports.filterEnabledInterrupts = filterEnabledInterrupts;
48
+ exports.readProjectAppSettings = readProjectAppSettings;
49
+ exports.updateProjectAppSettings = updateProjectAppSettings;
50
+ const fs = __importStar(require("node:fs"));
51
+ const os = __importStar(require("node:os"));
52
+ const path = __importStar(require("node:path"));
53
+ const project_anchor_1 = require("./project-anchor");
54
+ const DEFAULT_CONTEXT_THRESHOLD = 400_000;
55
+ function readJson(file) {
56
+ try {
57
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
58
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
59
+ ? parsed
60
+ : {};
61
+ }
62
+ catch {
63
+ return {};
64
+ }
65
+ }
66
+ function localAppSettingsPath(homeDir = os.homedir()) {
67
+ return path.join(homeDir, '.greprag', 'settings.json');
68
+ }
69
+ function readLocalAppSettings(homeDir = os.homedir()) {
70
+ const raw = readJson(localAppSettingsPath(homeDir));
71
+ const interrupts = raw.interrupts && typeof raw.interrupts === 'object'
72
+ ? raw.interrupts
73
+ : {};
74
+ const disabled = Array.isArray(interrupts.disabled)
75
+ ? [...new Set(interrupts.disabled.filter((id) => typeof id === 'string' && /^[a-z0-9-]{1,80}$/.test(id)))]
76
+ : [];
77
+ return { version: 1, interrupts: { disabled } };
78
+ }
79
+ function setInterruptEnabled(moduleId, enabled, homeDir = os.homedir()) {
80
+ if (!/^[a-z0-9-]{1,80}$/.test(moduleId))
81
+ throw new Error('Invalid feature id.');
82
+ const settings = readLocalAppSettings(homeDir);
83
+ const disabled = new Set(settings.interrupts.disabled);
84
+ if (enabled)
85
+ disabled.delete(moduleId);
86
+ else
87
+ disabled.add(moduleId);
88
+ const next = {
89
+ version: 1,
90
+ interrupts: { disabled: [...disabled].sort() },
91
+ };
92
+ const file = localAppSettingsPath(homeDir);
93
+ fs.mkdirSync(path.dirname(file), { recursive: true });
94
+ fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n', 'utf8');
95
+ return next;
96
+ }
97
+ /** Filter an Interrupt registry without importing it here (keeps the settings
98
+ * module free of reminder-registry cycles and useful to every harness). */
99
+ function filterEnabledInterrupts(modules, settings = readLocalAppSettings()) {
100
+ const disabled = new Set(settings.interrupts.disabled);
101
+ return modules.filter(module => !disabled.has(module.id));
102
+ }
103
+ function readProjectAppSettings(cwd) {
104
+ const anchor = (0, project_anchor_1.readAnchor)(cwd);
105
+ const raw = readJson(anchor.anchorPath);
106
+ return {
107
+ memoryCapture: anchor.memoryCapture,
108
+ sessionStartRecap: anchor.sessionStartRecap,
109
+ inboxNotify: anchor.inboxNotify,
110
+ crushWrap: raw.crush_wrap !== false,
111
+ contextGovernor: raw.context_governor !== false,
112
+ contextThreshold: typeof raw.context_threshold === 'number' && raw.context_threshold > 0
113
+ ? raw.context_threshold
114
+ : DEFAULT_CONTEXT_THRESHOLD,
115
+ pngArchive: raw.png_archive !== false,
116
+ emailAutosave: raw.email_autosave === true,
117
+ };
118
+ }
119
+ function assertProjectPatch(patch) {
120
+ const booleanKeys = [
121
+ 'memoryCapture', 'sessionStartRecap', 'crushWrap', 'contextGovernor',
122
+ 'pngArchive', 'emailAutosave',
123
+ ];
124
+ for (const key of booleanKeys) {
125
+ if (patch[key] !== undefined && typeof patch[key] !== 'boolean') {
126
+ throw new Error(`${key} must be true or false.`);
127
+ }
128
+ }
129
+ if (patch.inboxNotify !== undefined &&
130
+ !['every_turn', 'session_start_only', 'off'].includes(patch.inboxNotify)) {
131
+ throw new Error('inboxNotify must be every_turn, session_start_only, or off.');
132
+ }
133
+ if (patch.contextThreshold !== undefined &&
134
+ (!Number.isInteger(patch.contextThreshold) ||
135
+ patch.contextThreshold < 50_000 || patch.contextThreshold > 2_000_000)) {
136
+ throw new Error('contextThreshold must be an integer from 50,000 to 2,000,000.');
137
+ }
138
+ }
139
+ function updateProjectAppSettings(cwd, patch) {
140
+ assertProjectPatch(patch);
141
+ (0, project_anchor_1.ensureAnchor)(cwd);
142
+ const anchor = (0, project_anchor_1.readAnchor)(cwd);
143
+ const raw = readJson(anchor.anchorPath);
144
+ const mapping = [
145
+ ['memoryCapture', 'memory_capture'],
146
+ ['sessionStartRecap', 'session_start_recap'],
147
+ ['inboxNotify', 'inbox_notify'],
148
+ ['crushWrap', 'crush_wrap'],
149
+ ['contextGovernor', 'context_governor'],
150
+ ['contextThreshold', 'context_threshold'],
151
+ ['pngArchive', 'png_archive'],
152
+ ['emailAutosave', 'email_autosave'],
153
+ ];
154
+ for (const [inputKey, fileKey] of mapping) {
155
+ const value = patch[inputKey];
156
+ if (value !== undefined)
157
+ raw[fileKey] = value;
158
+ }
159
+ fs.mkdirSync(path.dirname(anchor.anchorPath), { recursive: true });
160
+ fs.writeFileSync(anchor.anchorPath, JSON.stringify(raw, null, 2) + '\n', 'utf8');
161
+ return readProjectAppSettings(cwd);
162
+ }