@codemem/server 0.0.0 → 0.20.0-alpha.2
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/LICENSE +21 -0
- package/dist/helpers.d.ts +19 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.test.d.ts +2 -0
- package/dist/helpers.test.d.ts.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1383 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +8 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/middleware.d.ts +33 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/routes/config.d.ts +13 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/memory.d.ts +9 -0
- package/dist/routes/memory.d.ts.map +1 -0
- package/dist/routes/observer-status.d.ts +16 -0
- package/dist/routes/observer-status.d.ts.map +1 -0
- package/dist/routes/raw-events.d.ts +10 -0
- package/dist/routes/raw-events.d.ts.map +1 -0
- package/dist/routes/stats.d.ts +13 -0
- package/dist/routes/stats.d.ts.map +1 -0
- package/dist/routes/sync.d.ts +9 -0
- package/dist/routes/sync.d.ts.map +1 -0
- package/dist/viewer-html.d.ts +8 -0
- package/dist/viewer-html.d.ts.map +1 -0
- package/package.json +45 -1
- package/static/app.js +3726 -0
- package/static/favicon.svg +14 -0
- package/static/index.html +1860 -0
package/static/app.js
ADDED
|
@@ -0,0 +1,3726 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
//#region src/lib/dom.ts
|
|
3
|
+
function el(tag, className, text) {
|
|
4
|
+
const node = document.createElement(tag);
|
|
5
|
+
if (className) node.className = className;
|
|
6
|
+
if (text !== void 0 && text !== null) node.textContent = String(text);
|
|
7
|
+
return node;
|
|
8
|
+
}
|
|
9
|
+
function $(id) {
|
|
10
|
+
return document.getElementById(id);
|
|
11
|
+
}
|
|
12
|
+
function $input(id) {
|
|
13
|
+
return document.getElementById(id);
|
|
14
|
+
}
|
|
15
|
+
function $select(id) {
|
|
16
|
+
return document.getElementById(id);
|
|
17
|
+
}
|
|
18
|
+
function $button(id) {
|
|
19
|
+
return document.getElementById(id);
|
|
20
|
+
}
|
|
21
|
+
function hide(element) {
|
|
22
|
+
if (element) element.hidden = true;
|
|
23
|
+
}
|
|
24
|
+
function show(element) {
|
|
25
|
+
if (element) element.hidden = false;
|
|
26
|
+
}
|
|
27
|
+
function escapeHtml(value) {
|
|
28
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
29
|
+
}
|
|
30
|
+
function escapeRegExp(value) {
|
|
31
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
32
|
+
}
|
|
33
|
+
function highlightText(text, query) {
|
|
34
|
+
const q = query.trim();
|
|
35
|
+
if (!q) return escapeHtml(text);
|
|
36
|
+
const safe = escapeHtml(text);
|
|
37
|
+
try {
|
|
38
|
+
const re = new RegExp(`(${escapeRegExp(q)})`, "ig");
|
|
39
|
+
return safe.replace(re, "<mark class=\"match\">$1</mark>");
|
|
40
|
+
} catch {
|
|
41
|
+
return safe;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function copyToClipboard(text, button) {
|
|
45
|
+
const prev = button.textContent;
|
|
46
|
+
try {
|
|
47
|
+
await navigator.clipboard.writeText(text);
|
|
48
|
+
button.textContent = "Copied";
|
|
49
|
+
} catch {
|
|
50
|
+
button.textContent = "Copy failed";
|
|
51
|
+
}
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
button.textContent = prev || "Copy";
|
|
54
|
+
}, 1200);
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/lib/theme.ts
|
|
58
|
+
var THEME_OPTIONS = [{
|
|
59
|
+
id: "light",
|
|
60
|
+
label: "Light",
|
|
61
|
+
mode: "light"
|
|
62
|
+
}, {
|
|
63
|
+
id: "dark",
|
|
64
|
+
label: "Dark",
|
|
65
|
+
mode: "dark"
|
|
66
|
+
}];
|
|
67
|
+
var THEME_STORAGE_KEY = "codemem-theme";
|
|
68
|
+
function resolveTheme(themeId) {
|
|
69
|
+
const exact = THEME_OPTIONS.find((t) => t.id === themeId);
|
|
70
|
+
if (exact) return exact;
|
|
71
|
+
const fallback = themeId.startsWith("dark") ? "dark" : "light";
|
|
72
|
+
return THEME_OPTIONS.find((t) => t.id === fallback) || THEME_OPTIONS[0];
|
|
73
|
+
}
|
|
74
|
+
function getTheme() {
|
|
75
|
+
const saved = localStorage.getItem(THEME_STORAGE_KEY);
|
|
76
|
+
if (saved) return resolveTheme(saved).id;
|
|
77
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
78
|
+
}
|
|
79
|
+
function setTheme(theme) {
|
|
80
|
+
const selected = resolveTheme(theme);
|
|
81
|
+
document.documentElement.setAttribute("data-theme", selected.mode);
|
|
82
|
+
document.documentElement.setAttribute("data-color-mode", selected.mode);
|
|
83
|
+
if (selected.id === selected.mode) document.documentElement.removeAttribute("data-theme-variant");
|
|
84
|
+
else document.documentElement.setAttribute("data-theme-variant", selected.id);
|
|
85
|
+
localStorage.setItem(THEME_STORAGE_KEY, selected.id);
|
|
86
|
+
}
|
|
87
|
+
function initThemeSelect(select) {
|
|
88
|
+
if (!select) return;
|
|
89
|
+
select.textContent = "";
|
|
90
|
+
THEME_OPTIONS.forEach((theme) => {
|
|
91
|
+
const option = document.createElement("option");
|
|
92
|
+
option.value = theme.id;
|
|
93
|
+
option.textContent = theme.label;
|
|
94
|
+
select.appendChild(option);
|
|
95
|
+
});
|
|
96
|
+
select.value = getTheme();
|
|
97
|
+
select.addEventListener("change", () => {
|
|
98
|
+
setTheme(select.value || "dark");
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/lib/state.ts
|
|
103
|
+
var TAB_KEY = "codemem-tab";
|
|
104
|
+
var FEED_FILTER_KEY = "codemem-feed-filter";
|
|
105
|
+
var FEED_SCOPE_KEY = "codemem-feed-scope";
|
|
106
|
+
var SYNC_DIAGNOSTICS_KEY = "codemem-sync-diagnostics";
|
|
107
|
+
var SYNC_PAIRING_KEY = "codemem-sync-pairing";
|
|
108
|
+
var SYNC_REDACT_KEY = "codemem-sync-redact";
|
|
109
|
+
var FEED_FILTERS = [
|
|
110
|
+
"all",
|
|
111
|
+
"observations",
|
|
112
|
+
"summaries"
|
|
113
|
+
];
|
|
114
|
+
var FEED_SCOPES = [
|
|
115
|
+
"all",
|
|
116
|
+
"mine",
|
|
117
|
+
"theirs"
|
|
118
|
+
];
|
|
119
|
+
var state = {
|
|
120
|
+
activeTab: "feed",
|
|
121
|
+
currentProject: "",
|
|
122
|
+
refreshState: "idle",
|
|
123
|
+
refreshInFlight: false,
|
|
124
|
+
refreshQueued: false,
|
|
125
|
+
refreshTimer: null,
|
|
126
|
+
feedTypeFilter: "all",
|
|
127
|
+
feedScopeFilter: "all",
|
|
128
|
+
feedQuery: "",
|
|
129
|
+
lastFeedItems: [],
|
|
130
|
+
lastFeedFilteredCount: 0,
|
|
131
|
+
lastFeedSignature: "",
|
|
132
|
+
pendingFeedItems: null,
|
|
133
|
+
itemViewState: /* @__PURE__ */ new Map(),
|
|
134
|
+
itemExpandState: /* @__PURE__ */ new Map(),
|
|
135
|
+
newItemKeys: /* @__PURE__ */ new Set(),
|
|
136
|
+
lastStatsPayload: null,
|
|
137
|
+
lastUsagePayload: null,
|
|
138
|
+
lastRawEventsPayload: null,
|
|
139
|
+
lastSyncStatus: null,
|
|
140
|
+
lastSyncActors: [],
|
|
141
|
+
lastSyncPeers: [],
|
|
142
|
+
lastSyncSharingReview: [],
|
|
143
|
+
lastSyncCoordinator: null,
|
|
144
|
+
lastSyncJoinRequests: [],
|
|
145
|
+
lastTeamInvite: null,
|
|
146
|
+
lastTeamJoin: null,
|
|
147
|
+
lastSyncAttempts: [],
|
|
148
|
+
lastSyncLegacyDevices: [],
|
|
149
|
+
pairingPayloadRaw: null,
|
|
150
|
+
pairingCommandRaw: "",
|
|
151
|
+
configDefaults: {},
|
|
152
|
+
configPath: "",
|
|
153
|
+
settingsDirty: false,
|
|
154
|
+
noticeTimer: null,
|
|
155
|
+
syncDiagnosticsOpen: false,
|
|
156
|
+
syncPairingOpen: false
|
|
157
|
+
};
|
|
158
|
+
function getActiveTab() {
|
|
159
|
+
const hash = window.location.hash.replace("#", "");
|
|
160
|
+
if ([
|
|
161
|
+
"feed",
|
|
162
|
+
"health",
|
|
163
|
+
"sync"
|
|
164
|
+
].includes(hash)) return hash;
|
|
165
|
+
const saved = localStorage.getItem(TAB_KEY);
|
|
166
|
+
if (saved && [
|
|
167
|
+
"feed",
|
|
168
|
+
"health",
|
|
169
|
+
"sync"
|
|
170
|
+
].includes(saved)) return saved;
|
|
171
|
+
return "feed";
|
|
172
|
+
}
|
|
173
|
+
function setActiveTab(tab) {
|
|
174
|
+
state.activeTab = tab;
|
|
175
|
+
window.location.hash = tab;
|
|
176
|
+
localStorage.setItem(TAB_KEY, tab);
|
|
177
|
+
}
|
|
178
|
+
function getFeedTypeFilter() {
|
|
179
|
+
const saved = localStorage.getItem(FEED_FILTER_KEY) || "all";
|
|
180
|
+
return FEED_FILTERS.includes(saved) ? saved : "all";
|
|
181
|
+
}
|
|
182
|
+
function getFeedScopeFilter() {
|
|
183
|
+
const saved = localStorage.getItem(FEED_SCOPE_KEY) || "all";
|
|
184
|
+
return FEED_SCOPES.includes(saved) ? saved : "all";
|
|
185
|
+
}
|
|
186
|
+
function setFeedTypeFilter(value) {
|
|
187
|
+
state.feedTypeFilter = FEED_FILTERS.includes(value) ? value : "all";
|
|
188
|
+
localStorage.setItem(FEED_FILTER_KEY, state.feedTypeFilter);
|
|
189
|
+
}
|
|
190
|
+
function setFeedScopeFilter(value) {
|
|
191
|
+
state.feedScopeFilter = FEED_SCOPES.includes(value) ? value : "all";
|
|
192
|
+
localStorage.setItem(FEED_SCOPE_KEY, state.feedScopeFilter);
|
|
193
|
+
}
|
|
194
|
+
function isSyncDiagnosticsOpen() {
|
|
195
|
+
return localStorage.getItem(SYNC_DIAGNOSTICS_KEY) === "1";
|
|
196
|
+
}
|
|
197
|
+
function setSyncPairingOpen(open) {
|
|
198
|
+
state.syncPairingOpen = open;
|
|
199
|
+
try {
|
|
200
|
+
localStorage.setItem(SYNC_PAIRING_KEY, open ? "1" : "0");
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
function isSyncRedactionEnabled() {
|
|
204
|
+
return localStorage.getItem(SYNC_REDACT_KEY) !== "0";
|
|
205
|
+
}
|
|
206
|
+
function setSyncRedactionEnabled(enabled) {
|
|
207
|
+
localStorage.setItem(SYNC_REDACT_KEY, enabled ? "1" : "0");
|
|
208
|
+
}
|
|
209
|
+
function initState() {
|
|
210
|
+
state.activeTab = getActiveTab();
|
|
211
|
+
state.feedTypeFilter = getFeedTypeFilter();
|
|
212
|
+
state.feedScopeFilter = getFeedScopeFilter();
|
|
213
|
+
state.syncDiagnosticsOpen = isSyncDiagnosticsOpen();
|
|
214
|
+
try {
|
|
215
|
+
state.syncPairingOpen = localStorage.getItem(SYNC_PAIRING_KEY) === "1";
|
|
216
|
+
} catch {
|
|
217
|
+
state.syncPairingOpen = false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/lib/api.ts
|
|
222
|
+
async function fetchJson(url) {
|
|
223
|
+
const resp = await fetch(url);
|
|
224
|
+
if (!resp.ok) throw new Error(`${url}: ${resp.status} ${resp.statusText}`);
|
|
225
|
+
return resp.json();
|
|
226
|
+
}
|
|
227
|
+
async function loadStats() {
|
|
228
|
+
return fetchJson("/api/stats");
|
|
229
|
+
}
|
|
230
|
+
async function loadUsage(project) {
|
|
231
|
+
return fetchJson(`/api/usage?project=${encodeURIComponent(project)}`);
|
|
232
|
+
}
|
|
233
|
+
async function loadSession(project) {
|
|
234
|
+
return fetchJson(`/api/session?project=${encodeURIComponent(project)}`);
|
|
235
|
+
}
|
|
236
|
+
async function loadRawEvents(project) {
|
|
237
|
+
return fetchJson(`/api/raw-events?project=${encodeURIComponent(project)}`);
|
|
238
|
+
}
|
|
239
|
+
function buildProjectParams(project, limit, offset, scope) {
|
|
240
|
+
const params = new URLSearchParams();
|
|
241
|
+
params.set("project", project || "");
|
|
242
|
+
if (typeof limit === "number") params.set("limit", String(limit));
|
|
243
|
+
if (typeof offset === "number") params.set("offset", String(offset));
|
|
244
|
+
if (scope) params.set("scope", scope);
|
|
245
|
+
return params.toString();
|
|
246
|
+
}
|
|
247
|
+
async function loadMemoriesPage(project, options) {
|
|
248
|
+
return fetchJson(`/api/memories?${buildProjectParams(project, options?.limit, options?.offset, options?.scope)}`);
|
|
249
|
+
}
|
|
250
|
+
async function updateMemoryVisibility(memoryId, visibility) {
|
|
251
|
+
const resp = await fetch("/api/memories/visibility", {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: { "Content-Type": "application/json" },
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
memory_id: memoryId,
|
|
256
|
+
visibility
|
|
257
|
+
})
|
|
258
|
+
});
|
|
259
|
+
const text = await resp.text();
|
|
260
|
+
const payload = text ? JSON.parse(text) : {};
|
|
261
|
+
if (!resp.ok) throw new Error(payload?.error || text || "request failed");
|
|
262
|
+
return payload;
|
|
263
|
+
}
|
|
264
|
+
async function loadSummariesPage(project, options) {
|
|
265
|
+
return fetchJson(`/api/summaries?${buildProjectParams(project, options?.limit, options?.offset, options?.scope)}`);
|
|
266
|
+
}
|
|
267
|
+
async function loadObserverStatus() {
|
|
268
|
+
return fetchJson("/api/observer-status");
|
|
269
|
+
}
|
|
270
|
+
async function loadConfig() {
|
|
271
|
+
return fetchJson("/api/config");
|
|
272
|
+
}
|
|
273
|
+
async function saveConfig(payload) {
|
|
274
|
+
const resp = await fetch("/api/config", {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: { "Content-Type": "application/json" },
|
|
277
|
+
body: JSON.stringify(payload)
|
|
278
|
+
});
|
|
279
|
+
const text = await resp.text();
|
|
280
|
+
let parsed = null;
|
|
281
|
+
if (text) try {
|
|
282
|
+
parsed = JSON.parse(text);
|
|
283
|
+
} catch {}
|
|
284
|
+
if (!resp.ok) {
|
|
285
|
+
const message = parsed && typeof parsed.error === "string" ? parsed.error : text || "request failed";
|
|
286
|
+
throw new Error(message);
|
|
287
|
+
}
|
|
288
|
+
return parsed;
|
|
289
|
+
}
|
|
290
|
+
async function loadSyncStatus(includeDiagnostics, project = "") {
|
|
291
|
+
const params = new URLSearchParams();
|
|
292
|
+
if (includeDiagnostics) params.set("includeDiagnostics", "1");
|
|
293
|
+
if (project) params.set("project", project);
|
|
294
|
+
return fetchJson(`/api/sync/status${params.size ? `?${params.toString()}` : ""}`);
|
|
295
|
+
}
|
|
296
|
+
async function createCoordinatorInvite(payload) {
|
|
297
|
+
const resp = await fetch("/api/sync/invites/create", {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: { "Content-Type": "application/json" },
|
|
300
|
+
body: JSON.stringify(payload)
|
|
301
|
+
});
|
|
302
|
+
const text = await resp.text();
|
|
303
|
+
const data = text ? JSON.parse(text) : {};
|
|
304
|
+
if (!resp.ok) throw new Error(data?.error || text || "request failed");
|
|
305
|
+
return data;
|
|
306
|
+
}
|
|
307
|
+
async function importCoordinatorInvite(invite) {
|
|
308
|
+
const resp = await fetch("/api/sync/invites/import", {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: { "Content-Type": "application/json" },
|
|
311
|
+
body: JSON.stringify({ invite })
|
|
312
|
+
});
|
|
313
|
+
const text = await resp.text();
|
|
314
|
+
const data = text ? JSON.parse(text) : {};
|
|
315
|
+
if (!resp.ok) throw new Error(data?.error || text || "request failed");
|
|
316
|
+
return data;
|
|
317
|
+
}
|
|
318
|
+
async function reviewJoinRequest(requestId, action) {
|
|
319
|
+
const resp = await fetch("/api/sync/join-requests/review", {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers: { "Content-Type": "application/json" },
|
|
322
|
+
body: JSON.stringify({
|
|
323
|
+
request_id: requestId,
|
|
324
|
+
action
|
|
325
|
+
})
|
|
326
|
+
});
|
|
327
|
+
const text = await resp.text();
|
|
328
|
+
const data = text ? JSON.parse(text) : {};
|
|
329
|
+
if (!resp.ok) throw new Error(data?.error || text || "request failed");
|
|
330
|
+
return data;
|
|
331
|
+
}
|
|
332
|
+
async function loadSyncActors() {
|
|
333
|
+
return fetchJson("/api/sync/actors");
|
|
334
|
+
}
|
|
335
|
+
async function loadPairing() {
|
|
336
|
+
return fetchJson("/api/sync/pairing?includeDiagnostics=1");
|
|
337
|
+
}
|
|
338
|
+
async function updatePeerScope(peerDeviceId, include, exclude, inheritGlobal = false) {
|
|
339
|
+
const resp = await fetch("/api/sync/peers/scope", {
|
|
340
|
+
method: "POST",
|
|
341
|
+
headers: { "Content-Type": "application/json" },
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
peer_device_id: peerDeviceId,
|
|
344
|
+
include,
|
|
345
|
+
exclude,
|
|
346
|
+
inherit_global: inheritGlobal
|
|
347
|
+
})
|
|
348
|
+
});
|
|
349
|
+
const text = await resp.text();
|
|
350
|
+
const payload = text ? JSON.parse(text) : {};
|
|
351
|
+
if (!resp.ok) throw new Error(payload?.error || text || "request failed");
|
|
352
|
+
return payload;
|
|
353
|
+
}
|
|
354
|
+
async function assignPeerActor(peerDeviceId, actorId) {
|
|
355
|
+
const resp = await fetch("/api/sync/peers/identity", {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: { "Content-Type": "application/json" },
|
|
358
|
+
body: JSON.stringify({
|
|
359
|
+
peer_device_id: peerDeviceId,
|
|
360
|
+
actor_id: actorId
|
|
361
|
+
})
|
|
362
|
+
});
|
|
363
|
+
const text = await resp.text();
|
|
364
|
+
const payload = text ? JSON.parse(text) : {};
|
|
365
|
+
if (!resp.ok) throw new Error(payload?.error || text || "request failed");
|
|
366
|
+
return payload;
|
|
367
|
+
}
|
|
368
|
+
async function createActor(displayName) {
|
|
369
|
+
const resp = await fetch("/api/sync/actors", {
|
|
370
|
+
method: "POST",
|
|
371
|
+
headers: { "Content-Type": "application/json" },
|
|
372
|
+
body: JSON.stringify({ display_name: displayName })
|
|
373
|
+
});
|
|
374
|
+
const text = await resp.text();
|
|
375
|
+
const payload = text ? JSON.parse(text) : {};
|
|
376
|
+
if (!resp.ok) throw new Error(payload?.error || text || "request failed");
|
|
377
|
+
return payload;
|
|
378
|
+
}
|
|
379
|
+
async function renameActor(actorId, displayName) {
|
|
380
|
+
const resp = await fetch("/api/sync/actors/rename", {
|
|
381
|
+
method: "POST",
|
|
382
|
+
headers: { "Content-Type": "application/json" },
|
|
383
|
+
body: JSON.stringify({
|
|
384
|
+
actor_id: actorId,
|
|
385
|
+
display_name: displayName
|
|
386
|
+
})
|
|
387
|
+
});
|
|
388
|
+
const text = await resp.text();
|
|
389
|
+
const payload = text ? JSON.parse(text) : {};
|
|
390
|
+
if (!resp.ok) throw new Error(payload?.error || text || "request failed");
|
|
391
|
+
return payload;
|
|
392
|
+
}
|
|
393
|
+
async function mergeActor(primaryActorId, secondaryActorId) {
|
|
394
|
+
const resp = await fetch("/api/sync/actors/merge", {
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: { "Content-Type": "application/json" },
|
|
397
|
+
body: JSON.stringify({
|
|
398
|
+
primary_actor_id: primaryActorId,
|
|
399
|
+
secondary_actor_id: secondaryActorId
|
|
400
|
+
})
|
|
401
|
+
});
|
|
402
|
+
const text = await resp.text();
|
|
403
|
+
const payload = text ? JSON.parse(text) : {};
|
|
404
|
+
if (!resp.ok) throw new Error(payload?.error || text || "request failed");
|
|
405
|
+
return payload;
|
|
406
|
+
}
|
|
407
|
+
async function claimLegacyDeviceIdentity(originDeviceId) {
|
|
408
|
+
const resp = await fetch("/api/sync/legacy-devices/claim", {
|
|
409
|
+
method: "POST",
|
|
410
|
+
headers: { "Content-Type": "application/json" },
|
|
411
|
+
body: JSON.stringify({ origin_device_id: originDeviceId })
|
|
412
|
+
});
|
|
413
|
+
const text = await resp.text();
|
|
414
|
+
const payload = text ? JSON.parse(text) : {};
|
|
415
|
+
if (!resp.ok) throw new Error(payload?.error || text || "request failed");
|
|
416
|
+
return payload;
|
|
417
|
+
}
|
|
418
|
+
async function loadProjects$1() {
|
|
419
|
+
return (await fetchJson("/api/projects")).projects || [];
|
|
420
|
+
}
|
|
421
|
+
async function triggerSync(address) {
|
|
422
|
+
const payload = address ? { address } : {};
|
|
423
|
+
await fetch("/api/sync/run", {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: { "Content-Type": "application/json" },
|
|
426
|
+
body: JSON.stringify(payload)
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/lib/format.ts
|
|
431
|
+
function formatDate(value) {
|
|
432
|
+
if (!value) return "n/a";
|
|
433
|
+
const date = new Date(value);
|
|
434
|
+
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
|
435
|
+
}
|
|
436
|
+
function formatTimestamp(value) {
|
|
437
|
+
if (!value) return "never";
|
|
438
|
+
const date = new Date(value);
|
|
439
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
440
|
+
return date.toLocaleString();
|
|
441
|
+
}
|
|
442
|
+
function formatRelativeTime(value) {
|
|
443
|
+
if (!value) return "n/a";
|
|
444
|
+
const date = new Date(value);
|
|
445
|
+
const ms = date.getTime();
|
|
446
|
+
if (Number.isNaN(ms)) return String(value);
|
|
447
|
+
const diff = Date.now() - ms;
|
|
448
|
+
const seconds = Math.round(diff / 1e3);
|
|
449
|
+
if (seconds < 10) return "just now";
|
|
450
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
451
|
+
const minutes = Math.round(seconds / 60);
|
|
452
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
453
|
+
const hours = Math.round(minutes / 60);
|
|
454
|
+
if (hours < 24) return `${hours}h ago`;
|
|
455
|
+
const days = Math.round(hours / 24);
|
|
456
|
+
if (days < 14) return `${days}d ago`;
|
|
457
|
+
return date.toLocaleDateString();
|
|
458
|
+
}
|
|
459
|
+
function secondsSince(value) {
|
|
460
|
+
if (!value) return null;
|
|
461
|
+
const ts = new Date(value).getTime();
|
|
462
|
+
if (!Number.isFinite(ts)) return null;
|
|
463
|
+
const delta = Math.floor((Date.now() - ts) / 1e3);
|
|
464
|
+
return delta >= 0 ? delta : 0;
|
|
465
|
+
}
|
|
466
|
+
function formatAgeShort(seconds) {
|
|
467
|
+
if (seconds === null || seconds === void 0) return "n/a";
|
|
468
|
+
if (seconds < 60) return `${seconds}s`;
|
|
469
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
470
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
|
471
|
+
return `${Math.floor(seconds / 86400)}d`;
|
|
472
|
+
}
|
|
473
|
+
function formatPercent(value) {
|
|
474
|
+
const num = Number(value);
|
|
475
|
+
if (!Number.isFinite(num)) return "n/a";
|
|
476
|
+
return `${Math.round(num * 100)}%`;
|
|
477
|
+
}
|
|
478
|
+
function formatMultiplier(saved, read) {
|
|
479
|
+
const savedNum = Number(saved || 0);
|
|
480
|
+
const readNum = Number(read || 0);
|
|
481
|
+
if (!Number.isFinite(savedNum) || !Number.isFinite(readNum) || readNum <= 0) return "n/a";
|
|
482
|
+
const factor = (savedNum + readNum) / readNum;
|
|
483
|
+
if (!Number.isFinite(factor) || factor <= 0) return "n/a";
|
|
484
|
+
return `${factor.toFixed(factor >= 10 ? 0 : 1)}x`;
|
|
485
|
+
}
|
|
486
|
+
function formatReductionPercent(saved, read) {
|
|
487
|
+
const savedNum = Number(saved || 0);
|
|
488
|
+
const readNum = Number(read || 0);
|
|
489
|
+
if (!Number.isFinite(savedNum) || !Number.isFinite(readNum)) return "n/a";
|
|
490
|
+
const total = savedNum + readNum;
|
|
491
|
+
if (total <= 0) return "n/a";
|
|
492
|
+
const pct = savedNum / total;
|
|
493
|
+
if (!Number.isFinite(pct)) return "n/a";
|
|
494
|
+
return `${Math.round(pct * 100)}%`;
|
|
495
|
+
}
|
|
496
|
+
function parsePercentValue(label) {
|
|
497
|
+
const text = String(label || "").trim();
|
|
498
|
+
if (!text.endsWith("%")) return null;
|
|
499
|
+
const raw = Number(text.replace("%", ""));
|
|
500
|
+
if (!Number.isFinite(raw)) return null;
|
|
501
|
+
return raw;
|
|
502
|
+
}
|
|
503
|
+
function normalize(text) {
|
|
504
|
+
return String(text || "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
505
|
+
}
|
|
506
|
+
function parseJsonArray(value) {
|
|
507
|
+
if (!value) return [];
|
|
508
|
+
if (Array.isArray(value)) return value;
|
|
509
|
+
if (typeof value === "string") try {
|
|
510
|
+
const parsed = JSON.parse(value);
|
|
511
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
512
|
+
} catch {
|
|
513
|
+
return [];
|
|
514
|
+
}
|
|
515
|
+
return [];
|
|
516
|
+
}
|
|
517
|
+
function titleCase(value) {
|
|
518
|
+
const text = String(value || "").trim();
|
|
519
|
+
if (!text) return "Unknown";
|
|
520
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
521
|
+
}
|
|
522
|
+
function toTitleLabel(value) {
|
|
523
|
+
return value.replace(/_/g, " ").split(" ").map((part) => part ? part[0].toUpperCase() + part.slice(1) : part).join(" ").trim();
|
|
524
|
+
}
|
|
525
|
+
function formatFileList(files, limit = 2) {
|
|
526
|
+
if (!files.length) return "";
|
|
527
|
+
const trimmed = files.map((f) => String(f).trim()).filter(Boolean);
|
|
528
|
+
const slice = trimmed.slice(0, limit);
|
|
529
|
+
const suffix = trimmed.length > limit ? ` +${trimmed.length - limit}` : "";
|
|
530
|
+
return `${slice.join(", ")}${suffix}`.trim();
|
|
531
|
+
}
|
|
532
|
+
function formatTagLabel(tag) {
|
|
533
|
+
if (!tag) return "";
|
|
534
|
+
const trimmed = String(tag).trim();
|
|
535
|
+
const colonIndex = trimmed.indexOf(":");
|
|
536
|
+
if (colonIndex === -1) return trimmed;
|
|
537
|
+
return trimmed.slice(0, colonIndex).trim();
|
|
538
|
+
}
|
|
539
|
+
//#endregion
|
|
540
|
+
//#region src/lib/notice.ts
|
|
541
|
+
var hideAbort = null;
|
|
542
|
+
function hideGlobalNotice() {
|
|
543
|
+
const notice = $("globalNotice");
|
|
544
|
+
if (!notice) return;
|
|
545
|
+
if (state.noticeTimer) {
|
|
546
|
+
clearTimeout(state.noticeTimer);
|
|
547
|
+
state.noticeTimer = null;
|
|
548
|
+
}
|
|
549
|
+
if (hideAbort) hideAbort.abort();
|
|
550
|
+
hideAbort = new AbortController();
|
|
551
|
+
notice.classList.add("hiding");
|
|
552
|
+
notice.addEventListener("animationend", () => {
|
|
553
|
+
hideAbort = null;
|
|
554
|
+
notice.hidden = true;
|
|
555
|
+
notice.textContent = "";
|
|
556
|
+
notice.classList.remove("success", "warning", "hiding");
|
|
557
|
+
}, {
|
|
558
|
+
once: true,
|
|
559
|
+
signal: hideAbort.signal
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
function showGlobalNotice(message, type = "success") {
|
|
563
|
+
const notice = $("globalNotice");
|
|
564
|
+
if (!notice || !message) return;
|
|
565
|
+
if (hideAbort) {
|
|
566
|
+
hideAbort.abort();
|
|
567
|
+
hideAbort = null;
|
|
568
|
+
}
|
|
569
|
+
notice.classList.remove("hiding");
|
|
570
|
+
notice.textContent = message;
|
|
571
|
+
notice.classList.remove("success", "warning");
|
|
572
|
+
notice.classList.add(type === "warning" ? "warning" : "success");
|
|
573
|
+
notice.hidden = false;
|
|
574
|
+
if (state.noticeTimer) clearTimeout(state.noticeTimer);
|
|
575
|
+
state.noticeTimer = setTimeout(() => {
|
|
576
|
+
hideGlobalNotice();
|
|
577
|
+
}, 12e3);
|
|
578
|
+
}
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/tabs/feed.ts
|
|
581
|
+
function mergeMetadata(metadata) {
|
|
582
|
+
if (!metadata || typeof metadata !== "object") return {};
|
|
583
|
+
const importMeta = metadata.import_metadata;
|
|
584
|
+
if (importMeta && typeof importMeta === "object") return {
|
|
585
|
+
...importMeta,
|
|
586
|
+
...metadata
|
|
587
|
+
};
|
|
588
|
+
return metadata;
|
|
589
|
+
}
|
|
590
|
+
function extractFactsFromBody(text) {
|
|
591
|
+
if (!text) return [];
|
|
592
|
+
const bullets = String(text).split("\n").map((l) => l.trim()).filter(Boolean).filter((l) => /^[-*\u2022]\s+/.test(l) || /^\d+\./.test(l));
|
|
593
|
+
if (!bullets.length) return [];
|
|
594
|
+
return bullets.map((l) => l.replace(/^[-*\u2022]\s+/, "").replace(/^\d+\.\s+/, ""));
|
|
595
|
+
}
|
|
596
|
+
function sentenceFacts(text, limit = 6) {
|
|
597
|
+
const raw = String(text || "").trim();
|
|
598
|
+
if (!raw) return [];
|
|
599
|
+
const parts = raw.replace(/\s+/g, " ").trim().split(/(?<=[.!?])\s+/).map((p) => p.trim()).filter(Boolean);
|
|
600
|
+
const facts = [];
|
|
601
|
+
for (const part of parts) {
|
|
602
|
+
if (part.length < 18) continue;
|
|
603
|
+
facts.push(part);
|
|
604
|
+
if (facts.length >= limit) break;
|
|
605
|
+
}
|
|
606
|
+
return facts;
|
|
607
|
+
}
|
|
608
|
+
function isLowSignalObservation(item) {
|
|
609
|
+
const title = normalize(item.title);
|
|
610
|
+
const body = normalize(item.body_text);
|
|
611
|
+
if (!title && !body) return true;
|
|
612
|
+
const combined = body || title;
|
|
613
|
+
if (combined.length < 10) return true;
|
|
614
|
+
if (title && body && title === body && combined.length < 40) return true;
|
|
615
|
+
const lead = title.charAt(0);
|
|
616
|
+
if ((lead === "└" || lead === "›") && combined.length < 40) return true;
|
|
617
|
+
if (title.startsWith("list ") && combined.length < 20) return true;
|
|
618
|
+
if (combined === "ls" || combined === "list ls") return true;
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
function itemSignature(item) {
|
|
622
|
+
return String(item.id ?? item.memory_id ?? item.observation_id ?? item.session_id ?? item.created_at_utc ?? item.created_at ?? "");
|
|
623
|
+
}
|
|
624
|
+
function itemKey(item) {
|
|
625
|
+
return `${String(item.kind || "").toLowerCase()}:${itemSignature(item)}`;
|
|
626
|
+
}
|
|
627
|
+
var OBSERVATION_PAGE_SIZE = 20;
|
|
628
|
+
var SUMMARY_PAGE_SIZE = 50;
|
|
629
|
+
var FEED_SCROLL_THRESHOLD_PX = 560;
|
|
630
|
+
var lastFeedProject = "";
|
|
631
|
+
var observationOffset = 0;
|
|
632
|
+
var summaryOffset = 0;
|
|
633
|
+
var observationHasMore = true;
|
|
634
|
+
var summaryHasMore = true;
|
|
635
|
+
var loadMoreInFlight = false;
|
|
636
|
+
var feedScrollHandlerBound = false;
|
|
637
|
+
var feedProjectGeneration = 0;
|
|
638
|
+
var lastFeedScope = "all";
|
|
639
|
+
function feedScopeLabel(scope) {
|
|
640
|
+
if (scope === "mine") return " · my memories";
|
|
641
|
+
if (scope === "theirs") return " · other actors";
|
|
642
|
+
return "";
|
|
643
|
+
}
|
|
644
|
+
function provenanceChip(label, variant = "") {
|
|
645
|
+
return el("span", `provenance-chip ${variant}`.trim(), label);
|
|
646
|
+
}
|
|
647
|
+
function trustStateLabel(trustState) {
|
|
648
|
+
if (trustState === "legacy_unknown") return "legacy provenance";
|
|
649
|
+
if (trustState === "unreviewed") return "unreviewed";
|
|
650
|
+
return trustState.replace(/_/g, " ");
|
|
651
|
+
}
|
|
652
|
+
function authorLabel(item) {
|
|
653
|
+
if (item?.owned_by_self === true) return "You";
|
|
654
|
+
const actorId = String(item.actor_id || "").trim();
|
|
655
|
+
const actorName = String(item.actor_display_name || "").trim();
|
|
656
|
+
if (actorId && actorId === state.lastStatsPayload?.identity?.actor_id) return "You";
|
|
657
|
+
return actorName || actorId || "Unknown author";
|
|
658
|
+
}
|
|
659
|
+
function resetPagination(project) {
|
|
660
|
+
lastFeedProject = project;
|
|
661
|
+
lastFeedScope = state.feedScopeFilter;
|
|
662
|
+
feedProjectGeneration += 1;
|
|
663
|
+
observationOffset = 0;
|
|
664
|
+
summaryOffset = 0;
|
|
665
|
+
observationHasMore = true;
|
|
666
|
+
summaryHasMore = true;
|
|
667
|
+
state.lastFeedItems = [];
|
|
668
|
+
state.pendingFeedItems = null;
|
|
669
|
+
state.lastFeedFilteredCount = 0;
|
|
670
|
+
state.lastFeedSignature = "";
|
|
671
|
+
state.newItemKeys.clear();
|
|
672
|
+
state.itemViewState.clear();
|
|
673
|
+
state.itemExpandState.clear();
|
|
674
|
+
}
|
|
675
|
+
function isNearFeedBottom() {
|
|
676
|
+
const root = document.documentElement;
|
|
677
|
+
const height = Math.max(root.scrollHeight, document.body.scrollHeight);
|
|
678
|
+
return window.innerHeight + window.scrollY >= height - FEED_SCROLL_THRESHOLD_PX;
|
|
679
|
+
}
|
|
680
|
+
function pageHasMore(payload, count, limit) {
|
|
681
|
+
const value = payload?.pagination?.has_more;
|
|
682
|
+
if (typeof value === "boolean") return value;
|
|
683
|
+
return count >= limit;
|
|
684
|
+
}
|
|
685
|
+
function pageNextOffset(payload, count) {
|
|
686
|
+
const value = payload?.pagination?.next_offset;
|
|
687
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
|
|
688
|
+
return count;
|
|
689
|
+
}
|
|
690
|
+
function hasMorePages() {
|
|
691
|
+
return observationHasMore || summaryHasMore;
|
|
692
|
+
}
|
|
693
|
+
function mergeFeedItems(currentItems, incomingItems) {
|
|
694
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
695
|
+
currentItems.forEach((item) => byKey.set(itemKey(item), item));
|
|
696
|
+
incomingItems.forEach((item) => byKey.set(itemKey(item), item));
|
|
697
|
+
return Array.from(byKey.values()).sort((a, b) => {
|
|
698
|
+
return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime();
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
function mergeRefreshFeedItems(currentItems, firstPageItems) {
|
|
702
|
+
const firstPageKeys = new Set(firstPageItems.map(itemKey));
|
|
703
|
+
return mergeFeedItems(currentItems.filter((item) => !firstPageKeys.has(itemKey(item))), firstPageItems);
|
|
704
|
+
}
|
|
705
|
+
function replaceFeedItem(updatedItem) {
|
|
706
|
+
const key = itemKey(updatedItem);
|
|
707
|
+
state.lastFeedItems = state.lastFeedItems.map((item) => itemKey(item) === key ? updatedItem : item);
|
|
708
|
+
}
|
|
709
|
+
function getSummaryObject(item) {
|
|
710
|
+
const preferredKeys = [
|
|
711
|
+
"request",
|
|
712
|
+
"outcome",
|
|
713
|
+
"plan",
|
|
714
|
+
"completed",
|
|
715
|
+
"learned",
|
|
716
|
+
"investigated",
|
|
717
|
+
"next",
|
|
718
|
+
"next_steps",
|
|
719
|
+
"notes"
|
|
720
|
+
];
|
|
721
|
+
const looksLikeSummary = (v) => {
|
|
722
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
723
|
+
return preferredKeys.some((k) => typeof v[k] === "string" && v[k].trim().length > 0);
|
|
724
|
+
};
|
|
725
|
+
if (item?.summary && typeof item.summary === "object" && !Array.isArray(item.summary)) return item.summary;
|
|
726
|
+
if (item?.summary?.summary && typeof item.summary.summary === "object") return item.summary.summary;
|
|
727
|
+
const metadata = item?.metadata_json;
|
|
728
|
+
if (looksLikeSummary(metadata)) return metadata;
|
|
729
|
+
if (looksLikeSummary(metadata?.summary)) return metadata.summary;
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
function observationViewData(item) {
|
|
733
|
+
const metadata = mergeMetadata(item?.metadata_json);
|
|
734
|
+
const summary = String(item?.subtitle || item?.body_text || "").trim();
|
|
735
|
+
const narrative = String(item?.narrative || metadata?.narrative || "").trim();
|
|
736
|
+
const normSummary = normalize(summary);
|
|
737
|
+
const normNarrative = normalize(narrative);
|
|
738
|
+
const narrativeDistinct = Boolean(narrative) && normNarrative !== normSummary;
|
|
739
|
+
const explicitFacts = parseJsonArray(item?.facts || metadata?.facts || []);
|
|
740
|
+
const fallbackFacts = explicitFacts.length ? explicitFacts : extractFactsFromBody(summary || narrative);
|
|
741
|
+
const derivedFacts = fallbackFacts.length ? fallbackFacts : sentenceFacts(summary);
|
|
742
|
+
return {
|
|
743
|
+
summary,
|
|
744
|
+
narrative,
|
|
745
|
+
facts: derivedFacts,
|
|
746
|
+
hasSummary: Boolean(summary),
|
|
747
|
+
hasFacts: derivedFacts.length > 0,
|
|
748
|
+
hasNarrative: narrativeDistinct
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
function observationViewModes(data) {
|
|
752
|
+
const modes = [];
|
|
753
|
+
if (data.hasSummary) modes.push({
|
|
754
|
+
id: "summary",
|
|
755
|
+
label: "Summary"
|
|
756
|
+
});
|
|
757
|
+
if (data.hasFacts) modes.push({
|
|
758
|
+
id: "facts",
|
|
759
|
+
label: "Facts"
|
|
760
|
+
});
|
|
761
|
+
if (data.hasNarrative) modes.push({
|
|
762
|
+
id: "narrative",
|
|
763
|
+
label: "Narrative"
|
|
764
|
+
});
|
|
765
|
+
return modes;
|
|
766
|
+
}
|
|
767
|
+
function defaultObservationView(data) {
|
|
768
|
+
if (data.hasSummary) return "summary";
|
|
769
|
+
if (data.hasFacts) return "facts";
|
|
770
|
+
return "narrative";
|
|
771
|
+
}
|
|
772
|
+
function shouldClampBody(mode, data) {
|
|
773
|
+
if (mode === "facts") return false;
|
|
774
|
+
if (mode === "summary") return data.summary.length > 260;
|
|
775
|
+
return data.narrative.length > 320;
|
|
776
|
+
}
|
|
777
|
+
function clampClass(mode) {
|
|
778
|
+
return mode === "summary" ? ["clamp", "clamp-3"] : ["clamp", "clamp-5"];
|
|
779
|
+
}
|
|
780
|
+
function isSafeHref(value) {
|
|
781
|
+
const href = String(value || "").trim();
|
|
782
|
+
if (!href) return false;
|
|
783
|
+
if (href.startsWith("#") || href.startsWith("/")) return true;
|
|
784
|
+
const lower = href.toLowerCase();
|
|
785
|
+
return lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:");
|
|
786
|
+
}
|
|
787
|
+
function sanitizeHtml(html) {
|
|
788
|
+
const template = document.createElement("template");
|
|
789
|
+
template.innerHTML = String(html || "");
|
|
790
|
+
const allowedTags = new Set([
|
|
791
|
+
"p",
|
|
792
|
+
"br",
|
|
793
|
+
"strong",
|
|
794
|
+
"em",
|
|
795
|
+
"code",
|
|
796
|
+
"pre",
|
|
797
|
+
"ul",
|
|
798
|
+
"ol",
|
|
799
|
+
"li",
|
|
800
|
+
"blockquote",
|
|
801
|
+
"a",
|
|
802
|
+
"h1",
|
|
803
|
+
"h2",
|
|
804
|
+
"h3",
|
|
805
|
+
"h4",
|
|
806
|
+
"h5",
|
|
807
|
+
"h6",
|
|
808
|
+
"hr"
|
|
809
|
+
]);
|
|
810
|
+
template.content.querySelectorAll("script, iframe, object, embed, link, style").forEach((node) => {
|
|
811
|
+
node.remove();
|
|
812
|
+
});
|
|
813
|
+
template.content.querySelectorAll("*").forEach((node) => {
|
|
814
|
+
const tag = node.tagName.toLowerCase();
|
|
815
|
+
if (!allowedTags.has(tag)) {
|
|
816
|
+
node.replaceWith(document.createTextNode(node.textContent || ""));
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const allowedAttrs = tag === "a" ? new Set(["href", "title"]) : /* @__PURE__ */ new Set();
|
|
820
|
+
for (const attr of Array.from(node.attributes)) {
|
|
821
|
+
const name = attr.name.toLowerCase();
|
|
822
|
+
if (!allowedAttrs.has(name)) node.removeAttribute(attr.name);
|
|
823
|
+
}
|
|
824
|
+
if (tag === "a") if (!isSafeHref(node.getAttribute("href") || "")) node.removeAttribute("href");
|
|
825
|
+
else {
|
|
826
|
+
node.setAttribute("rel", "noopener noreferrer");
|
|
827
|
+
node.setAttribute("target", "_blank");
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
return template.innerHTML;
|
|
831
|
+
}
|
|
832
|
+
function renderMarkdownSafe(value) {
|
|
833
|
+
const source = String(value || "");
|
|
834
|
+
try {
|
|
835
|
+
return sanitizeHtml(globalThis.marked.parse(source));
|
|
836
|
+
} catch {
|
|
837
|
+
return source.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
function renderSummaryObject(summary) {
|
|
841
|
+
const preferred = [
|
|
842
|
+
"request",
|
|
843
|
+
"outcome",
|
|
844
|
+
"plan",
|
|
845
|
+
"completed",
|
|
846
|
+
"learned",
|
|
847
|
+
"investigated",
|
|
848
|
+
"next",
|
|
849
|
+
"next_steps",
|
|
850
|
+
"notes"
|
|
851
|
+
];
|
|
852
|
+
const keys = Object.keys(summary);
|
|
853
|
+
const ordered = preferred.filter((k) => keys.includes(k));
|
|
854
|
+
const container = el("div", "feed-body facts");
|
|
855
|
+
let wrote = false;
|
|
856
|
+
ordered.forEach((key) => {
|
|
857
|
+
const content = String(summary[key] || "").trim();
|
|
858
|
+
if (!content) return;
|
|
859
|
+
wrote = true;
|
|
860
|
+
const row = el("div", "summary-section");
|
|
861
|
+
const label = el("div", "summary-section-label", toTitleLabel(key));
|
|
862
|
+
const value = el("div", "summary-section-content");
|
|
863
|
+
value.innerHTML = renderMarkdownSafe(content);
|
|
864
|
+
row.append(label, value);
|
|
865
|
+
container.appendChild(row);
|
|
866
|
+
});
|
|
867
|
+
return wrote ? container : null;
|
|
868
|
+
}
|
|
869
|
+
function renderFacts(facts) {
|
|
870
|
+
const trimmed = facts.map((f) => String(f || "").trim()).filter(Boolean);
|
|
871
|
+
if (!trimmed.length) return null;
|
|
872
|
+
const container = el("div", "feed-body");
|
|
873
|
+
const list = document.createElement("ul");
|
|
874
|
+
trimmed.forEach((f) => {
|
|
875
|
+
const li = document.createElement("li");
|
|
876
|
+
li.textContent = f;
|
|
877
|
+
list.appendChild(li);
|
|
878
|
+
});
|
|
879
|
+
container.appendChild(list);
|
|
880
|
+
return container;
|
|
881
|
+
}
|
|
882
|
+
function renderNarrative(narrative) {
|
|
883
|
+
const content = String(narrative || "").trim();
|
|
884
|
+
if (!content) return null;
|
|
885
|
+
const body = el("div", "feed-body");
|
|
886
|
+
body.innerHTML = renderMarkdownSafe(content);
|
|
887
|
+
return body;
|
|
888
|
+
}
|
|
889
|
+
function renderObservationBody(data, mode) {
|
|
890
|
+
if (mode === "facts") return renderFacts(data.facts) || el("div", "feed-body");
|
|
891
|
+
if (mode === "narrative") return renderNarrative(data.narrative) || el("div", "feed-body");
|
|
892
|
+
return renderNarrative(data.summary) || el("div", "feed-body");
|
|
893
|
+
}
|
|
894
|
+
function renderViewToggle(modes, active, onSelect) {
|
|
895
|
+
if (modes.length <= 1) return null;
|
|
896
|
+
const toggle = el("div", "feed-toggle");
|
|
897
|
+
modes.forEach((mode) => {
|
|
898
|
+
const btn = el("button", "toggle-button", mode.label);
|
|
899
|
+
btn.dataset.filter = mode.id;
|
|
900
|
+
btn.classList.toggle("active", mode.id === active);
|
|
901
|
+
btn.addEventListener("click", () => onSelect(mode.id));
|
|
902
|
+
toggle.appendChild(btn);
|
|
903
|
+
});
|
|
904
|
+
return toggle;
|
|
905
|
+
}
|
|
906
|
+
function createTagChip(tag) {
|
|
907
|
+
const display = formatTagLabel(tag);
|
|
908
|
+
if (!display) return null;
|
|
909
|
+
const chip = el("span", "tag-chip", display);
|
|
910
|
+
chip.title = String(tag);
|
|
911
|
+
return chip;
|
|
912
|
+
}
|
|
913
|
+
function renderFeedItem(item) {
|
|
914
|
+
const kindValue = String(item.kind || "session_summary").toLowerCase();
|
|
915
|
+
const isSessionSummary = kindValue === "session_summary";
|
|
916
|
+
const metadata = mergeMetadata(item?.metadata_json);
|
|
917
|
+
const card = el("div", `feed-item ${kindValue}`.trim());
|
|
918
|
+
const rowKey = itemKey(item);
|
|
919
|
+
card.dataset.key = rowKey;
|
|
920
|
+
if (state.newItemKeys.has(rowKey)) {
|
|
921
|
+
card.classList.add("new-item");
|
|
922
|
+
setTimeout(() => {
|
|
923
|
+
card.classList.remove("new-item");
|
|
924
|
+
state.newItemKeys.delete(rowKey);
|
|
925
|
+
}, 700);
|
|
926
|
+
}
|
|
927
|
+
const header = el("div", "feed-card-header");
|
|
928
|
+
const titleWrap = el("div", "feed-header");
|
|
929
|
+
const defaultTitle = item.title || "(untitled)";
|
|
930
|
+
const displayTitle = isSessionSummary && metadata?.request ? metadata.request : defaultTitle;
|
|
931
|
+
const title = el("div", "feed-title title");
|
|
932
|
+
title.innerHTML = highlightText(displayTitle, state.feedQuery);
|
|
933
|
+
const kind = el("span", `kind-pill ${kindValue}`.trim(), kindValue.replace(/_/g, " "));
|
|
934
|
+
titleWrap.append(kind, title);
|
|
935
|
+
const rightWrap = el("div", "feed-actions");
|
|
936
|
+
const createdAtRaw = item.created_at || item.created_at_utc;
|
|
937
|
+
const age = el("div", "small feed-age", formatRelativeTime(createdAtRaw));
|
|
938
|
+
age.title = formatDate(createdAtRaw);
|
|
939
|
+
const footerRight = el("div", "feed-footer-right");
|
|
940
|
+
let bodyNode = el("div", "feed-body");
|
|
941
|
+
if (isSessionSummary) {
|
|
942
|
+
const summaryObj = getSummaryObject({ metadata_json: metadata });
|
|
943
|
+
bodyNode = (summaryObj ? renderSummaryObject(summaryObj) : null) || renderNarrative(String(item.body_text || "")) || bodyNode;
|
|
944
|
+
} else {
|
|
945
|
+
const data = observationViewData({
|
|
946
|
+
...item,
|
|
947
|
+
metadata_json: metadata
|
|
948
|
+
});
|
|
949
|
+
const modes = observationViewModes(data);
|
|
950
|
+
const defaultView = defaultObservationView(data);
|
|
951
|
+
const key = itemKey(item);
|
|
952
|
+
const stored = state.itemViewState.get(key);
|
|
953
|
+
let activeMode = stored && modes.some((m) => m.id === stored) ? stored : defaultView;
|
|
954
|
+
state.itemViewState.set(key, activeMode);
|
|
955
|
+
bodyNode = renderObservationBody(data, activeMode);
|
|
956
|
+
const setExpandControl = (mode) => {
|
|
957
|
+
footerRight.textContent = "";
|
|
958
|
+
const expandKey = `${key}:${mode}`;
|
|
959
|
+
const expanded = state.itemExpandState.get(expandKey) === true;
|
|
960
|
+
if (!shouldClampBody(mode, data)) return;
|
|
961
|
+
const btn = el("button", "feed-expand", expanded ? "Collapse" : "Expand");
|
|
962
|
+
btn.addEventListener("click", () => {
|
|
963
|
+
const next = !(state.itemExpandState.get(expandKey) === true);
|
|
964
|
+
state.itemExpandState.set(expandKey, next);
|
|
965
|
+
if (next) {
|
|
966
|
+
bodyNode.classList.remove("clamp", "clamp-3", "clamp-5");
|
|
967
|
+
btn.textContent = "Collapse";
|
|
968
|
+
} else {
|
|
969
|
+
bodyNode.classList.add(...clampClass(mode));
|
|
970
|
+
btn.textContent = "Expand";
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
footerRight.appendChild(btn);
|
|
974
|
+
};
|
|
975
|
+
const expandKey = `${key}:${activeMode}`;
|
|
976
|
+
const expanded = state.itemExpandState.get(expandKey) === true;
|
|
977
|
+
if (shouldClampBody(activeMode, data) && !expanded) bodyNode.classList.add(...clampClass(activeMode));
|
|
978
|
+
setExpandControl(activeMode);
|
|
979
|
+
const toggle = renderViewToggle(modes, activeMode, (mode) => {
|
|
980
|
+
activeMode = mode;
|
|
981
|
+
state.itemViewState.set(key, mode);
|
|
982
|
+
const nextBody = renderObservationBody(data, mode);
|
|
983
|
+
const nextExpandKey = `${key}:${mode}`;
|
|
984
|
+
const nextExpanded = state.itemExpandState.get(nextExpandKey) === true;
|
|
985
|
+
if (shouldClampBody(mode, data) && !nextExpanded) nextBody.classList.add(...clampClass(mode));
|
|
986
|
+
card.replaceChild(nextBody, bodyNode);
|
|
987
|
+
bodyNode = nextBody;
|
|
988
|
+
setExpandControl(mode);
|
|
989
|
+
if (toggle) toggle.querySelectorAll(".toggle-button").forEach((b) => {
|
|
990
|
+
b.classList.toggle("active", b.dataset.filter === mode);
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
if (toggle) rightWrap.appendChild(toggle);
|
|
994
|
+
}
|
|
995
|
+
rightWrap.appendChild(age);
|
|
996
|
+
header.append(titleWrap, rightWrap);
|
|
997
|
+
const meta = el("div", "feed-meta");
|
|
998
|
+
const tags = parseJsonArray(item.tags || []);
|
|
999
|
+
const files = parseJsonArray(item.files || []);
|
|
1000
|
+
const project = item.project || "";
|
|
1001
|
+
const actor = authorLabel(item);
|
|
1002
|
+
const visibility = String(item.visibility || metadata?.visibility || "private").trim();
|
|
1003
|
+
const workspaceKind = String(item.workspace_kind || metadata?.workspace_kind || "").trim();
|
|
1004
|
+
const originSource = String(item.origin_source || metadata?.origin_source || "").trim();
|
|
1005
|
+
const originDeviceId = String(item.origin_device_id || metadata?.origin_device_id || "").trim();
|
|
1006
|
+
const trustState = String(item.trust_state || metadata?.trust_state || "").trim();
|
|
1007
|
+
const tagContent = tags.length ? ` · ${tags.map((t) => formatTagLabel(t)).join(", ")}` : "";
|
|
1008
|
+
const fileContent = files.length ? ` · ${formatFileList(files)}` : "";
|
|
1009
|
+
meta.textContent = `${project ? `Project: ${project}` : "Project: n/a"}${tagContent}${fileContent}`;
|
|
1010
|
+
const provenance = el("div", "feed-provenance");
|
|
1011
|
+
provenance.appendChild(provenanceChip(actor, actor === "You" ? "mine" : "author"));
|
|
1012
|
+
provenance.appendChild(provenanceChip(visibility || "private", visibility || "private"));
|
|
1013
|
+
if (workspaceKind && workspaceKind !== visibility) provenance.appendChild(provenanceChip(workspaceKind, "workspace"));
|
|
1014
|
+
if (originSource) provenance.appendChild(provenanceChip(originSource, "source"));
|
|
1015
|
+
if (originDeviceId && actor !== "You") provenance.appendChild(provenanceChip(originDeviceId, "device"));
|
|
1016
|
+
if (trustState && trustState !== "trusted") provenance.appendChild(provenanceChip(trustStateLabel(trustState), "trust"));
|
|
1017
|
+
const footer = el("div", "feed-footer");
|
|
1018
|
+
const footerLeft = el("div", "feed-footer-left");
|
|
1019
|
+
const filesWrap = el("div", "feed-files");
|
|
1020
|
+
const tagsWrap = el("div", "feed-tags");
|
|
1021
|
+
files.forEach((f) => filesWrap.appendChild(el("span", "feed-file", f)));
|
|
1022
|
+
tags.forEach((t) => {
|
|
1023
|
+
const chip = createTagChip(t);
|
|
1024
|
+
if (chip) tagsWrap.appendChild(chip);
|
|
1025
|
+
});
|
|
1026
|
+
if (filesWrap.childElementCount) footerLeft.appendChild(filesWrap);
|
|
1027
|
+
if (tagsWrap.childElementCount) footerLeft.appendChild(tagsWrap);
|
|
1028
|
+
const memoryId = Number(item.id || 0);
|
|
1029
|
+
if (Boolean(item.owned_by_self) && memoryId > 0) {
|
|
1030
|
+
const visibilityControls = el("div", "feed-visibility-controls");
|
|
1031
|
+
const visibilitySelect = document.createElement("select");
|
|
1032
|
+
visibilitySelect.className = "feed-visibility-select";
|
|
1033
|
+
[{
|
|
1034
|
+
value: "private",
|
|
1035
|
+
label: "Only me"
|
|
1036
|
+
}, {
|
|
1037
|
+
value: "shared",
|
|
1038
|
+
label: "Share with peers"
|
|
1039
|
+
}].forEach((optionData) => {
|
|
1040
|
+
const option = document.createElement("option");
|
|
1041
|
+
option.value = optionData.value;
|
|
1042
|
+
option.textContent = optionData.label;
|
|
1043
|
+
option.selected = optionData.value === visibility;
|
|
1044
|
+
visibilitySelect.appendChild(option);
|
|
1045
|
+
});
|
|
1046
|
+
visibilitySelect.setAttribute("aria-label", `Visibility for ${String(item.title || "memory")}`);
|
|
1047
|
+
const visibilityNote = el("div", "feed-visibility-note", visibility === "shared" ? "This memory can sync to peers allowed by your project filters." : "This memory stays local unless the peer is assigned to your local actor.");
|
|
1048
|
+
visibilitySelect.addEventListener("change", () => {
|
|
1049
|
+
visibilityNote.textContent = visibilitySelect.value === "shared" ? "This memory can sync to peers allowed by your project filters." : "This memory stays local unless the peer is assigned to your local actor.";
|
|
1050
|
+
});
|
|
1051
|
+
visibilitySelect.addEventListener("change", async () => {
|
|
1052
|
+
const previousVisibility = visibility;
|
|
1053
|
+
visibilitySelect.disabled = true;
|
|
1054
|
+
try {
|
|
1055
|
+
const payload = await updateMemoryVisibility(memoryId, visibilitySelect.value);
|
|
1056
|
+
if (payload?.item) {
|
|
1057
|
+
replaceFeedItem(payload.item);
|
|
1058
|
+
updateFeedView(true);
|
|
1059
|
+
}
|
|
1060
|
+
showGlobalNotice(visibilitySelect.value === "shared" ? "Memory will now sync as shared context." : "Memory is private again.");
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
visibilitySelect.value = previousVisibility;
|
|
1063
|
+
visibilityNote.textContent = previousVisibility === "shared" ? "This memory can sync to peers allowed by your project filters." : "This memory stays local unless the peer is assigned to your local actor.";
|
|
1064
|
+
showGlobalNotice(error instanceof Error ? error.message : "Failed to save visibility.", "warning");
|
|
1065
|
+
} finally {
|
|
1066
|
+
visibilitySelect.disabled = false;
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
visibilityControls.append(visibilitySelect, visibilityNote);
|
|
1070
|
+
footerLeft.appendChild(visibilityControls);
|
|
1071
|
+
}
|
|
1072
|
+
footer.append(footerLeft, footerRight);
|
|
1073
|
+
card.append(header, provenance, meta, bodyNode, footer);
|
|
1074
|
+
return card;
|
|
1075
|
+
}
|
|
1076
|
+
function filterByType(items) {
|
|
1077
|
+
if (state.feedTypeFilter === "observations") return items.filter((i) => String(i.kind || "").toLowerCase() !== "session_summary");
|
|
1078
|
+
if (state.feedTypeFilter === "summaries") return items.filter((i) => String(i.kind || "").toLowerCase() === "session_summary");
|
|
1079
|
+
return items;
|
|
1080
|
+
}
|
|
1081
|
+
function filterByQuery(items) {
|
|
1082
|
+
const query = normalize(state.feedQuery);
|
|
1083
|
+
if (!query) return items;
|
|
1084
|
+
return items.filter((item) => {
|
|
1085
|
+
return [
|
|
1086
|
+
normalize(item?.title),
|
|
1087
|
+
normalize(item?.body_text),
|
|
1088
|
+
normalize(item?.kind),
|
|
1089
|
+
parseJsonArray(item?.tags || []).map((t) => normalize(t)).join(" "),
|
|
1090
|
+
normalize(item?.project)
|
|
1091
|
+
].join(" ").trim().includes(query);
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
function computeSignature(items) {
|
|
1095
|
+
const parts = items.map((i) => `${itemSignature(i)}:${i.kind || ""}:${i.created_at_utc || i.created_at || ""}`);
|
|
1096
|
+
return `${state.feedTypeFilter}|${state.feedScopeFilter}|${state.currentProject}|${normalize(state.feedQuery)}|${parts.join("|")}`;
|
|
1097
|
+
}
|
|
1098
|
+
function countNewItems(nextItems, currentItems) {
|
|
1099
|
+
const seen = new Set(currentItems.map(itemKey));
|
|
1100
|
+
return nextItems.filter((i) => !seen.has(itemKey(i))).length;
|
|
1101
|
+
}
|
|
1102
|
+
async function loadMoreFeedPage() {
|
|
1103
|
+
if (loadMoreInFlight || !hasMorePages()) return;
|
|
1104
|
+
const requestProject = state.currentProject || "";
|
|
1105
|
+
const requestGeneration = feedProjectGeneration;
|
|
1106
|
+
const startObservationOffset = observationOffset;
|
|
1107
|
+
const startSummaryOffset = summaryOffset;
|
|
1108
|
+
loadMoreInFlight = true;
|
|
1109
|
+
try {
|
|
1110
|
+
const [observations, summaries] = await Promise.all([observationHasMore ? loadMemoriesPage(requestProject, {
|
|
1111
|
+
limit: OBSERVATION_PAGE_SIZE,
|
|
1112
|
+
offset: startObservationOffset,
|
|
1113
|
+
scope: state.feedScopeFilter
|
|
1114
|
+
}) : Promise.resolve({
|
|
1115
|
+
items: [],
|
|
1116
|
+
pagination: {
|
|
1117
|
+
has_more: false,
|
|
1118
|
+
next_offset: startObservationOffset
|
|
1119
|
+
}
|
|
1120
|
+
}), summaryHasMore ? loadSummariesPage(requestProject, {
|
|
1121
|
+
limit: SUMMARY_PAGE_SIZE,
|
|
1122
|
+
offset: startSummaryOffset,
|
|
1123
|
+
scope: state.feedScopeFilter
|
|
1124
|
+
}) : Promise.resolve({
|
|
1125
|
+
items: [],
|
|
1126
|
+
pagination: {
|
|
1127
|
+
has_more: false,
|
|
1128
|
+
next_offset: startSummaryOffset
|
|
1129
|
+
}
|
|
1130
|
+
})]);
|
|
1131
|
+
if (requestGeneration !== feedProjectGeneration || requestProject !== (state.currentProject || "")) return;
|
|
1132
|
+
const summaryItems = summaries.items || [];
|
|
1133
|
+
const observationItems = observations.items || [];
|
|
1134
|
+
const filtered = observationItems.filter((i) => !isLowSignalObservation(i));
|
|
1135
|
+
state.lastFeedFilteredCount += observationItems.length - filtered.length;
|
|
1136
|
+
summaryHasMore = pageHasMore(summaries, summaryItems.length, SUMMARY_PAGE_SIZE);
|
|
1137
|
+
observationHasMore = pageHasMore(observations, observationItems.length, OBSERVATION_PAGE_SIZE);
|
|
1138
|
+
summaryOffset = pageNextOffset(summaries, startSummaryOffset + summaryItems.length);
|
|
1139
|
+
observationOffset = pageNextOffset(observations, startObservationOffset + observationItems.length);
|
|
1140
|
+
const incoming = [...summaryItems, ...filtered];
|
|
1141
|
+
const feedItems = mergeFeedItems(state.lastFeedItems, incoming);
|
|
1142
|
+
if (countNewItems(feedItems, state.lastFeedItems)) {
|
|
1143
|
+
const seen = new Set(state.lastFeedItems.map(itemKey));
|
|
1144
|
+
feedItems.forEach((item) => {
|
|
1145
|
+
if (!seen.has(itemKey(item))) state.newItemKeys.add(itemKey(item));
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
state.lastFeedItems = feedItems;
|
|
1149
|
+
updateFeedView();
|
|
1150
|
+
} finally {
|
|
1151
|
+
loadMoreInFlight = false;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function maybeLoadMoreFeedPage() {
|
|
1155
|
+
if (state.activeTab !== "feed") return;
|
|
1156
|
+
if (!hasMorePages()) return;
|
|
1157
|
+
if (!isNearFeedBottom()) return;
|
|
1158
|
+
loadMoreFeedPage();
|
|
1159
|
+
}
|
|
1160
|
+
function renderProjectSwitchLoadingState() {
|
|
1161
|
+
const feedList = document.getElementById("feedList");
|
|
1162
|
+
const feedMeta = document.getElementById("feedMeta");
|
|
1163
|
+
if (feedList) {
|
|
1164
|
+
feedList.textContent = "";
|
|
1165
|
+
feedList.appendChild(el("div", "small", "Loading selected project..."));
|
|
1166
|
+
}
|
|
1167
|
+
if (feedMeta) feedMeta.textContent = "Loading selected project...";
|
|
1168
|
+
}
|
|
1169
|
+
function initFeedTab() {
|
|
1170
|
+
const feedTypeToggle = document.getElementById("feedTypeToggle");
|
|
1171
|
+
const feedScopeToggle = document.getElementById("feedScopeToggle");
|
|
1172
|
+
const feedSearch = document.getElementById("feedSearch");
|
|
1173
|
+
updateFeedTypeToggle();
|
|
1174
|
+
updateFeedScopeToggle();
|
|
1175
|
+
feedTypeToggle?.addEventListener("click", (e) => {
|
|
1176
|
+
const target = e.target?.closest?.("button");
|
|
1177
|
+
if (!target) return;
|
|
1178
|
+
setFeedTypeFilter(target.dataset.filter || "all");
|
|
1179
|
+
updateFeedTypeToggle();
|
|
1180
|
+
updateFeedView();
|
|
1181
|
+
});
|
|
1182
|
+
feedScopeToggle?.addEventListener("click", (e) => {
|
|
1183
|
+
const target = e.target?.closest?.("button");
|
|
1184
|
+
if (!target) return;
|
|
1185
|
+
setFeedScopeFilter(target.dataset.filter || "all");
|
|
1186
|
+
updateFeedScopeToggle();
|
|
1187
|
+
loadFeedData();
|
|
1188
|
+
});
|
|
1189
|
+
feedSearch?.addEventListener("input", () => {
|
|
1190
|
+
state.feedQuery = feedSearch.value || "";
|
|
1191
|
+
updateFeedView();
|
|
1192
|
+
});
|
|
1193
|
+
if (!feedScrollHandlerBound) {
|
|
1194
|
+
window.addEventListener("scroll", () => {
|
|
1195
|
+
maybeLoadMoreFeedPage();
|
|
1196
|
+
}, { passive: true });
|
|
1197
|
+
feedScrollHandlerBound = true;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
function updateFeedTypeToggle() {
|
|
1201
|
+
const toggle = document.getElementById("feedTypeToggle");
|
|
1202
|
+
if (!toggle) return;
|
|
1203
|
+
toggle.querySelectorAll(".toggle-button").forEach((btn) => {
|
|
1204
|
+
const active = (btn.dataset?.filter || "all") === state.feedTypeFilter;
|
|
1205
|
+
btn.classList.toggle("active", active);
|
|
1206
|
+
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
function updateFeedScopeToggle() {
|
|
1210
|
+
const toggle = document.getElementById("feedScopeToggle");
|
|
1211
|
+
if (!toggle) return;
|
|
1212
|
+
toggle.querySelectorAll(".toggle-button").forEach((btn) => {
|
|
1213
|
+
const active = (btn.dataset?.filter || "all") === state.feedScopeFilter;
|
|
1214
|
+
btn.classList.toggle("active", active);
|
|
1215
|
+
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
function updateFeedView(force = false) {
|
|
1219
|
+
const feedList = document.getElementById("feedList");
|
|
1220
|
+
const feedMeta = document.getElementById("feedMeta");
|
|
1221
|
+
if (!feedList) return;
|
|
1222
|
+
const scrollY = window.scrollY;
|
|
1223
|
+
const visible = filterByQuery(filterByType(state.lastFeedItems));
|
|
1224
|
+
const filterLabel = state.feedTypeFilter === "observations" ? " · observations" : state.feedTypeFilter === "summaries" ? " · session summaries" : "";
|
|
1225
|
+
const scopeLabel = feedScopeLabel(state.feedScopeFilter);
|
|
1226
|
+
const sig = computeSignature(visible);
|
|
1227
|
+
const changed = force || sig !== state.lastFeedSignature;
|
|
1228
|
+
state.lastFeedSignature = sig;
|
|
1229
|
+
if (feedMeta) {
|
|
1230
|
+
const filteredLabel = !state.feedQuery.trim() && state.lastFeedFilteredCount ? ` · ${state.lastFeedFilteredCount} observations filtered` : "";
|
|
1231
|
+
const queryLabel = state.feedQuery.trim() ? ` · matching "${state.feedQuery.trim()}"` : "";
|
|
1232
|
+
const moreLabel = hasMorePages() ? " · scroll for more" : "";
|
|
1233
|
+
feedMeta.textContent = `${visible.length} items${filterLabel}${scopeLabel}${queryLabel}${filteredLabel}${moreLabel}`;
|
|
1234
|
+
}
|
|
1235
|
+
if (changed) {
|
|
1236
|
+
feedList.textContent = "";
|
|
1237
|
+
if (!visible.length) feedList.appendChild(el("div", "small", "No memories yet."));
|
|
1238
|
+
else visible.forEach((item) => feedList.appendChild(renderFeedItem(item)));
|
|
1239
|
+
if (typeof globalThis.lucide !== "undefined") globalThis.lucide.createIcons();
|
|
1240
|
+
}
|
|
1241
|
+
window.scrollTo({ top: scrollY });
|
|
1242
|
+
maybeLoadMoreFeedPage();
|
|
1243
|
+
}
|
|
1244
|
+
async function loadFeedData() {
|
|
1245
|
+
const project = state.currentProject || "";
|
|
1246
|
+
const scopeChanged = state.feedScopeFilter !== lastFeedScope;
|
|
1247
|
+
if (project !== lastFeedProject || scopeChanged) {
|
|
1248
|
+
resetPagination(project);
|
|
1249
|
+
renderProjectSwitchLoadingState();
|
|
1250
|
+
}
|
|
1251
|
+
const requestGeneration = feedProjectGeneration;
|
|
1252
|
+
const observationsLimit = OBSERVATION_PAGE_SIZE;
|
|
1253
|
+
const summariesLimit = SUMMARY_PAGE_SIZE;
|
|
1254
|
+
const [observations, summaries] = await Promise.all([loadMemoriesPage(project, {
|
|
1255
|
+
limit: observationsLimit,
|
|
1256
|
+
offset: 0,
|
|
1257
|
+
scope: state.feedScopeFilter
|
|
1258
|
+
}), loadSummariesPage(project, {
|
|
1259
|
+
limit: summariesLimit,
|
|
1260
|
+
offset: 0,
|
|
1261
|
+
scope: state.feedScopeFilter
|
|
1262
|
+
})]);
|
|
1263
|
+
if (requestGeneration !== feedProjectGeneration || project !== (state.currentProject || "")) return;
|
|
1264
|
+
const summaryItems = summaries.items || [];
|
|
1265
|
+
const observationItems = observations.items || [];
|
|
1266
|
+
const filtered = observationItems.filter((i) => !isLowSignalObservation(i));
|
|
1267
|
+
const filteredCount = observationItems.length - filtered.length;
|
|
1268
|
+
const firstPageFeedItems = [...summaryItems, ...filtered].sort((a, b) => {
|
|
1269
|
+
return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime();
|
|
1270
|
+
});
|
|
1271
|
+
const feedItems = mergeRefreshFeedItems(state.lastFeedItems, firstPageFeedItems);
|
|
1272
|
+
if (countNewItems(feedItems, state.lastFeedItems)) {
|
|
1273
|
+
const seen = new Set(state.lastFeedItems.map(itemKey));
|
|
1274
|
+
feedItems.forEach((item) => {
|
|
1275
|
+
if (!seen.has(itemKey(item))) state.newItemKeys.add(itemKey(item));
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
state.pendingFeedItems = null;
|
|
1279
|
+
state.lastFeedItems = feedItems;
|
|
1280
|
+
state.lastFeedFilteredCount = Math.max(state.lastFeedFilteredCount, filteredCount);
|
|
1281
|
+
summaryHasMore = pageHasMore(summaries, summaryItems.length, summariesLimit);
|
|
1282
|
+
observationHasMore = pageHasMore(observations, observationItems.length, observationsLimit);
|
|
1283
|
+
summaryOffset = Math.max(summaryOffset, pageNextOffset(summaries, summaryItems.length));
|
|
1284
|
+
observationOffset = Math.max(observationOffset, pageNextOffset(observations, observationItems.length));
|
|
1285
|
+
lastFeedScope = state.feedScopeFilter;
|
|
1286
|
+
updateFeedView();
|
|
1287
|
+
}
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region src/tabs/health.ts
|
|
1290
|
+
function buildHealthCard({ label, value, detail, icon, className, title }) {
|
|
1291
|
+
const card = el("div", `stat${className ? ` ${className}` : ""}`);
|
|
1292
|
+
if (title) {
|
|
1293
|
+
card.title = title;
|
|
1294
|
+
card.style.cursor = "help";
|
|
1295
|
+
}
|
|
1296
|
+
if (icon) {
|
|
1297
|
+
const iconNode = document.createElement("i");
|
|
1298
|
+
iconNode.setAttribute("data-lucide", icon);
|
|
1299
|
+
iconNode.className = "stat-icon";
|
|
1300
|
+
card.appendChild(iconNode);
|
|
1301
|
+
}
|
|
1302
|
+
const content = el("div", "stat-content");
|
|
1303
|
+
content.append(el("div", "value", value), el("div", "label", label));
|
|
1304
|
+
if (detail) content.appendChild(el("div", "small", detail));
|
|
1305
|
+
card.appendChild(content);
|
|
1306
|
+
return card;
|
|
1307
|
+
}
|
|
1308
|
+
function renderActionList$1(container, actions) {
|
|
1309
|
+
if (!container) return;
|
|
1310
|
+
container.textContent = "";
|
|
1311
|
+
if (!actions.length) {
|
|
1312
|
+
container.hidden = true;
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
container.hidden = false;
|
|
1316
|
+
actions.slice(0, 3).forEach((item) => {
|
|
1317
|
+
const row = el("div", "health-action");
|
|
1318
|
+
const textWrap = el("div", "health-action-text");
|
|
1319
|
+
textWrap.textContent = item.label;
|
|
1320
|
+
if (item.command) textWrap.appendChild(el("span", "health-action-command", item.command));
|
|
1321
|
+
const btnWrap = el("div", "health-action-buttons");
|
|
1322
|
+
if (item.action) {
|
|
1323
|
+
const actionBtn = el("button", "settings-button", item.actionLabel || "Run");
|
|
1324
|
+
actionBtn.addEventListener("click", async () => {
|
|
1325
|
+
actionBtn.disabled = true;
|
|
1326
|
+
actionBtn.textContent = "Running…";
|
|
1327
|
+
try {
|
|
1328
|
+
await item.action();
|
|
1329
|
+
} catch {}
|
|
1330
|
+
actionBtn.disabled = false;
|
|
1331
|
+
actionBtn.textContent = item.actionLabel || "Run";
|
|
1332
|
+
});
|
|
1333
|
+
btnWrap.appendChild(actionBtn);
|
|
1334
|
+
}
|
|
1335
|
+
if (item.command) {
|
|
1336
|
+
const copyBtn = el("button", "settings-button health-action-copy", "Copy");
|
|
1337
|
+
copyBtn.addEventListener("click", () => copyToClipboard(item.command, copyBtn));
|
|
1338
|
+
btnWrap.appendChild(copyBtn);
|
|
1339
|
+
}
|
|
1340
|
+
row.append(textWrap, btnWrap);
|
|
1341
|
+
container.appendChild(row);
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
function renderHealthOverview() {
|
|
1345
|
+
const healthGrid = document.getElementById("healthGrid");
|
|
1346
|
+
const healthMeta = document.getElementById("healthMeta");
|
|
1347
|
+
const healthActions = document.getElementById("healthActions");
|
|
1348
|
+
const healthDot = document.getElementById("healthDot");
|
|
1349
|
+
if (!healthGrid || !healthMeta) return;
|
|
1350
|
+
healthGrid.textContent = "";
|
|
1351
|
+
const stats = state.lastStatsPayload || {};
|
|
1352
|
+
const usagePayload = state.lastUsagePayload || {};
|
|
1353
|
+
const raw = state.lastRawEventsPayload && typeof state.lastRawEventsPayload === "object" ? state.lastRawEventsPayload : {};
|
|
1354
|
+
const syncStatus = state.lastSyncStatus || {};
|
|
1355
|
+
const reliability = stats.reliability || {};
|
|
1356
|
+
const counts = reliability.counts || {};
|
|
1357
|
+
const rates = reliability.rates || {};
|
|
1358
|
+
const dbStats = stats.database || {};
|
|
1359
|
+
const totals = usagePayload.totals_filtered || usagePayload.totals || usagePayload.totals_global || stats.usage?.totals || {};
|
|
1360
|
+
const recentPacks = Array.isArray(usagePayload.recent_packs) ? usagePayload.recent_packs : [];
|
|
1361
|
+
const lastPackAt = recentPacks.length ? recentPacks[0]?.created_at : null;
|
|
1362
|
+
const latestPackMeta = recentPacks.length ? recentPacks[0]?.metadata_json || {} : {};
|
|
1363
|
+
const latestPackDeduped = Number(latestPackMeta?.exact_duplicates_collapsed || 0);
|
|
1364
|
+
const rawPending = Number(raw.pending || 0);
|
|
1365
|
+
const erroredBatches = Number(counts.errored_batches || 0);
|
|
1366
|
+
const flushSuccessRate = Number(rates.flush_success_rate ?? 1);
|
|
1367
|
+
const droppedRate = Number(rates.dropped_event_rate || 0);
|
|
1368
|
+
const reductionLabel = formatReductionPercent(totals.tokens_saved, totals.tokens_read);
|
|
1369
|
+
const reductionPercent = parsePercentValue(reductionLabel);
|
|
1370
|
+
const tagCoverage = Number(dbStats.tags_coverage || 0);
|
|
1371
|
+
const syncState = String(syncStatus.daemon_state || "unknown");
|
|
1372
|
+
const syncStateLabel = syncState === "offline-peers" ? "Offline peers" : titleCase(syncState);
|
|
1373
|
+
const peerCount = Array.isArray(state.lastSyncPeers) ? state.lastSyncPeers.length : 0;
|
|
1374
|
+
const syncDisabled = syncState === "disabled" || syncStatus.enabled === false;
|
|
1375
|
+
const syncOfflinePeers = syncState === "offline-peers";
|
|
1376
|
+
const syncNoPeers = !syncDisabled && peerCount === 0;
|
|
1377
|
+
const syncCardValue = syncDisabled ? "Disabled" : syncNoPeers ? "No peers" : syncStateLabel;
|
|
1378
|
+
const syncAgeSeconds = secondsSince(syncStatus.last_sync_at || syncStatus.last_sync_at_utc || null);
|
|
1379
|
+
const packAgeSeconds = secondsSince(lastPackAt);
|
|
1380
|
+
const syncLooksStale = syncAgeSeconds !== null && syncAgeSeconds > 7200;
|
|
1381
|
+
const hasBacklog = rawPending >= 200;
|
|
1382
|
+
let riskScore = 0;
|
|
1383
|
+
const drivers = [];
|
|
1384
|
+
if (rawPending >= 1e3) {
|
|
1385
|
+
riskScore += 40;
|
|
1386
|
+
drivers.push("high raw-event backlog");
|
|
1387
|
+
} else if (rawPending >= 200) {
|
|
1388
|
+
riskScore += 24;
|
|
1389
|
+
drivers.push("growing raw-event backlog");
|
|
1390
|
+
}
|
|
1391
|
+
if (erroredBatches > 0 && rawPending >= 200) {
|
|
1392
|
+
riskScore += erroredBatches >= 5 ? 10 : 6;
|
|
1393
|
+
drivers.push("batch errors during backlog pressure");
|
|
1394
|
+
}
|
|
1395
|
+
if (flushSuccessRate < .95) {
|
|
1396
|
+
riskScore += 20;
|
|
1397
|
+
drivers.push("lower flush success");
|
|
1398
|
+
}
|
|
1399
|
+
if (droppedRate > .02) {
|
|
1400
|
+
riskScore += 24;
|
|
1401
|
+
drivers.push("high dropped-event rate");
|
|
1402
|
+
} else if (droppedRate > .005) {
|
|
1403
|
+
riskScore += 10;
|
|
1404
|
+
drivers.push("non-trivial dropped-event rate");
|
|
1405
|
+
}
|
|
1406
|
+
if (!syncDisabled && !syncNoPeers) {
|
|
1407
|
+
if (syncState === "error") {
|
|
1408
|
+
riskScore += 36;
|
|
1409
|
+
drivers.push("sync daemon reports errors");
|
|
1410
|
+
} else if (syncState === "stopped") {
|
|
1411
|
+
riskScore += 22;
|
|
1412
|
+
drivers.push("sync daemon stopped");
|
|
1413
|
+
} else if (syncState === "degraded") {
|
|
1414
|
+
riskScore += 20;
|
|
1415
|
+
drivers.push("sync daemon degraded");
|
|
1416
|
+
}
|
|
1417
|
+
if (syncOfflinePeers) {
|
|
1418
|
+
riskScore += 4;
|
|
1419
|
+
drivers.push("all peers currently offline");
|
|
1420
|
+
if (syncLooksStale) {
|
|
1421
|
+
riskScore += 4;
|
|
1422
|
+
drivers.push("offline peers and sync not recent");
|
|
1423
|
+
}
|
|
1424
|
+
} else if (syncLooksStale) {
|
|
1425
|
+
riskScore += 26;
|
|
1426
|
+
drivers.push("sync looks stale");
|
|
1427
|
+
} else if (syncAgeSeconds !== null && syncAgeSeconds > 1800) {
|
|
1428
|
+
riskScore += 12;
|
|
1429
|
+
drivers.push("sync not recent");
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
if (reductionPercent !== null && reductionPercent < 10) {
|
|
1433
|
+
riskScore += 8;
|
|
1434
|
+
drivers.push("low retrieval reduction");
|
|
1435
|
+
}
|
|
1436
|
+
if (packAgeSeconds !== null && packAgeSeconds > 86400) {
|
|
1437
|
+
riskScore += 12;
|
|
1438
|
+
drivers.push("memory pack activity is old");
|
|
1439
|
+
}
|
|
1440
|
+
let statusLabel = "Healthy";
|
|
1441
|
+
let statusClass = "status-healthy";
|
|
1442
|
+
if (riskScore >= 60) {
|
|
1443
|
+
statusLabel = "Attention";
|
|
1444
|
+
statusClass = "status-attention";
|
|
1445
|
+
} else if (riskScore >= 25) {
|
|
1446
|
+
statusLabel = "Degraded";
|
|
1447
|
+
statusClass = "status-degraded";
|
|
1448
|
+
}
|
|
1449
|
+
if (healthDot) {
|
|
1450
|
+
healthDot.className = `health-dot ${statusClass}`;
|
|
1451
|
+
healthDot.title = statusLabel;
|
|
1452
|
+
}
|
|
1453
|
+
const retrievalDetail = `${Number(totals.tokens_saved || 0).toLocaleString()} saved tokens · ${latestPackDeduped.toLocaleString()} deduped in latest pack`;
|
|
1454
|
+
const pipelineDetail = rawPending > 0 ? "Queue is actively draining" : "Queue is clear";
|
|
1455
|
+
const syncDetail = syncDisabled ? "Sync disabled" : syncNoPeers ? "No peers configured" : syncOfflinePeers ? `${peerCount} peers offline · last sync ${formatAgeShort(syncAgeSeconds)} ago` : `${peerCount} peers · last sync ${formatAgeShort(syncAgeSeconds)} ago`;
|
|
1456
|
+
const freshnessDetail = `last pack ${formatAgeShort(packAgeSeconds)} ago`;
|
|
1457
|
+
[
|
|
1458
|
+
buildHealthCard({
|
|
1459
|
+
label: "Overall health",
|
|
1460
|
+
value: statusLabel,
|
|
1461
|
+
detail: `Weighted score ${riskScore}`,
|
|
1462
|
+
icon: "heart-pulse",
|
|
1463
|
+
className: `health-primary ${statusClass}`,
|
|
1464
|
+
title: drivers.length ? `Main signals: ${drivers.join(", ")}` : "No major risk signals detected"
|
|
1465
|
+
}),
|
|
1466
|
+
buildHealthCard({
|
|
1467
|
+
label: "Pipeline health",
|
|
1468
|
+
value: `${rawPending.toLocaleString()} pending`,
|
|
1469
|
+
detail: pipelineDetail,
|
|
1470
|
+
icon: "workflow",
|
|
1471
|
+
title: "Raw-event queue pressure and flush reliability"
|
|
1472
|
+
}),
|
|
1473
|
+
buildHealthCard({
|
|
1474
|
+
label: "Retrieval impact",
|
|
1475
|
+
value: reductionLabel,
|
|
1476
|
+
detail: retrievalDetail,
|
|
1477
|
+
icon: "sparkles",
|
|
1478
|
+
title: "Reduction from memory reuse across recent usage"
|
|
1479
|
+
}),
|
|
1480
|
+
buildHealthCard({
|
|
1481
|
+
label: "Sync health",
|
|
1482
|
+
value: syncCardValue,
|
|
1483
|
+
detail: syncDetail,
|
|
1484
|
+
icon: "refresh-cw",
|
|
1485
|
+
title: "Daemon state and sync recency"
|
|
1486
|
+
}),
|
|
1487
|
+
buildHealthCard({
|
|
1488
|
+
label: "Data freshness",
|
|
1489
|
+
value: formatAgeShort(packAgeSeconds),
|
|
1490
|
+
detail: freshnessDetail,
|
|
1491
|
+
icon: "clock-3",
|
|
1492
|
+
title: "Recency of last memory pack activity"
|
|
1493
|
+
})
|
|
1494
|
+
].forEach((c) => healthGrid.appendChild(c));
|
|
1495
|
+
const triggerSync$1 = () => triggerSync();
|
|
1496
|
+
const recommendations = [];
|
|
1497
|
+
if (hasBacklog) {
|
|
1498
|
+
recommendations.push({
|
|
1499
|
+
label: "Pipeline needs attention. Check queue health first.",
|
|
1500
|
+
command: "uv run codemem raw-events-status"
|
|
1501
|
+
});
|
|
1502
|
+
recommendations.push({
|
|
1503
|
+
label: "Then retry failed batches for impacted sessions.",
|
|
1504
|
+
command: "uv run codemem raw-events-retry <opencode_session_id>"
|
|
1505
|
+
});
|
|
1506
|
+
} else if (syncState === "stopped") recommendations.push({
|
|
1507
|
+
label: "Sync daemon is stopped. Start the background service.",
|
|
1508
|
+
command: "uv run codemem sync start"
|
|
1509
|
+
});
|
|
1510
|
+
else if (!syncDisabled && !syncNoPeers && (syncState === "error" || syncState === "degraded")) {
|
|
1511
|
+
recommendations.push({
|
|
1512
|
+
label: "Sync is unhealthy. Restart and run one immediate pass.",
|
|
1513
|
+
command: "uv run codemem sync restart",
|
|
1514
|
+
action: triggerSync$1,
|
|
1515
|
+
actionLabel: "Sync now"
|
|
1516
|
+
});
|
|
1517
|
+
recommendations.push({
|
|
1518
|
+
label: "Then run doctor to see root cause details.",
|
|
1519
|
+
command: "uv run codemem sync doctor"
|
|
1520
|
+
});
|
|
1521
|
+
} else if (!syncDisabled && !syncNoPeers && syncLooksStale) recommendations.push({
|
|
1522
|
+
label: "Sync is stale. Run one immediate sync pass.",
|
|
1523
|
+
command: "uv run codemem sync once",
|
|
1524
|
+
action: triggerSync$1,
|
|
1525
|
+
actionLabel: "Sync now"
|
|
1526
|
+
});
|
|
1527
|
+
if (tagCoverage > 0 && tagCoverage < .7 && recommendations.length < 2) recommendations.push({
|
|
1528
|
+
label: "Tag coverage is low. Preview backfill impact.",
|
|
1529
|
+
command: "uv run codemem backfill-tags --dry-run"
|
|
1530
|
+
});
|
|
1531
|
+
renderActionList$1(healthActions, recommendations);
|
|
1532
|
+
healthMeta.textContent = drivers.length ? `Why this status: ${drivers.join(", ")}.` : "Healthy right now. Diagnostics stay available if you want details.";
|
|
1533
|
+
if (typeof globalThis.lucide !== "undefined") globalThis.lucide.createIcons();
|
|
1534
|
+
}
|
|
1535
|
+
function renderStats() {
|
|
1536
|
+
const statsGrid = document.getElementById("statsGrid");
|
|
1537
|
+
const metaLine = document.getElementById("metaLine");
|
|
1538
|
+
if (!statsGrid) return;
|
|
1539
|
+
const stats = state.lastStatsPayload || {};
|
|
1540
|
+
const usagePayload = state.lastUsagePayload || {};
|
|
1541
|
+
const raw = state.lastRawEventsPayload && typeof state.lastRawEventsPayload === "object" ? state.lastRawEventsPayload : {};
|
|
1542
|
+
const db = stats.database || {};
|
|
1543
|
+
const project = state.currentProject;
|
|
1544
|
+
const totalsGlobal = usagePayload?.totals_global || usagePayload?.totals || stats.usage?.totals || {};
|
|
1545
|
+
const totalsFiltered = usagePayload?.totals_filtered || null;
|
|
1546
|
+
const isFiltered = !!(project && totalsFiltered);
|
|
1547
|
+
const usage = isFiltered ? totalsFiltered : totalsGlobal;
|
|
1548
|
+
const rawSessions = Number(raw.sessions || 0);
|
|
1549
|
+
const rawPending = Number(raw.pending || 0);
|
|
1550
|
+
const globalLineWork = isFiltered ? `\nGlobal: ${Number(totalsGlobal.work_investment_tokens || 0).toLocaleString()} invested` : "";
|
|
1551
|
+
const globalLineRead = isFiltered ? `\nGlobal: ${Number(totalsGlobal.tokens_read || 0).toLocaleString()} read` : "";
|
|
1552
|
+
const globalLineSaved = isFiltered ? `\nGlobal: ${Number(totalsGlobal.tokens_saved || 0).toLocaleString()} saved` : "";
|
|
1553
|
+
const items = [
|
|
1554
|
+
{
|
|
1555
|
+
label: isFiltered ? "Savings (project)" : "Savings",
|
|
1556
|
+
value: Number(usage.tokens_saved || 0),
|
|
1557
|
+
tooltip: "Tokens saved by reusing compressed memories" + globalLineSaved,
|
|
1558
|
+
icon: "trending-up"
|
|
1559
|
+
},
|
|
1560
|
+
{
|
|
1561
|
+
label: isFiltered ? "Injected (project)" : "Injected",
|
|
1562
|
+
value: Number(usage.tokens_read || 0),
|
|
1563
|
+
tooltip: "Tokens injected into context (pack size)" + globalLineRead,
|
|
1564
|
+
icon: "book-open"
|
|
1565
|
+
},
|
|
1566
|
+
{
|
|
1567
|
+
label: isFiltered ? "Reduction (project)" : "Reduction",
|
|
1568
|
+
value: formatReductionPercent(usage.tokens_saved, usage.tokens_read),
|
|
1569
|
+
tooltip: `Percent reduction from reuse. Factor: ${formatMultiplier(usage.tokens_saved, usage.tokens_read)}.` + globalLineRead + globalLineSaved,
|
|
1570
|
+
icon: "percent"
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
label: isFiltered ? "Work investment (project)" : "Work investment",
|
|
1574
|
+
value: Number(usage.work_investment_tokens || 0),
|
|
1575
|
+
tooltip: "Token cost of unique discovery groups" + globalLineWork,
|
|
1576
|
+
icon: "pencil"
|
|
1577
|
+
},
|
|
1578
|
+
{
|
|
1579
|
+
label: "Active memories",
|
|
1580
|
+
value: db.active_memory_items || 0,
|
|
1581
|
+
icon: "check-circle"
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
label: "Embedding coverage",
|
|
1585
|
+
value: formatPercent(db.vector_coverage),
|
|
1586
|
+
tooltip: "Share of active memories with embeddings",
|
|
1587
|
+
icon: "layers"
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
label: "Tag coverage",
|
|
1591
|
+
value: formatPercent(db.tags_coverage),
|
|
1592
|
+
tooltip: "Share of active memories with tags",
|
|
1593
|
+
icon: "tag"
|
|
1594
|
+
}
|
|
1595
|
+
];
|
|
1596
|
+
if (rawPending > 0) items.push({
|
|
1597
|
+
label: "Raw events pending",
|
|
1598
|
+
value: rawPending,
|
|
1599
|
+
tooltip: "Pending raw events waiting to be flushed",
|
|
1600
|
+
icon: "activity"
|
|
1601
|
+
});
|
|
1602
|
+
else if (rawSessions > 0) items.push({
|
|
1603
|
+
label: "Raw sessions",
|
|
1604
|
+
value: rawSessions,
|
|
1605
|
+
tooltip: "Sessions with pending raw events",
|
|
1606
|
+
icon: "inbox"
|
|
1607
|
+
});
|
|
1608
|
+
statsGrid.textContent = "";
|
|
1609
|
+
items.forEach((item) => {
|
|
1610
|
+
const stat = el("div", "stat");
|
|
1611
|
+
if (item.tooltip) {
|
|
1612
|
+
stat.title = item.tooltip;
|
|
1613
|
+
stat.style.cursor = "help";
|
|
1614
|
+
}
|
|
1615
|
+
const icon = document.createElement("i");
|
|
1616
|
+
icon.setAttribute("data-lucide", item.icon);
|
|
1617
|
+
icon.className = "stat-icon";
|
|
1618
|
+
const content = el("div", "stat-content");
|
|
1619
|
+
const displayValue = typeof item.value === "number" ? item.value.toLocaleString() : item.value == null ? "n/a" : String(item.value);
|
|
1620
|
+
content.append(el("div", "value", displayValue), el("div", "label", item.label));
|
|
1621
|
+
stat.append(icon, content);
|
|
1622
|
+
statsGrid.appendChild(stat);
|
|
1623
|
+
});
|
|
1624
|
+
if (metaLine) {
|
|
1625
|
+
const projectSuffix = project ? ` · project: ${project}` : "";
|
|
1626
|
+
metaLine.textContent = `DB: ${db.path || "unknown"} · ${Math.round((db.size_bytes || 0) / 1024)} KB${projectSuffix}`;
|
|
1627
|
+
}
|
|
1628
|
+
if (typeof globalThis.lucide !== "undefined") globalThis.lucide.createIcons();
|
|
1629
|
+
}
|
|
1630
|
+
function renderSessionSummary() {
|
|
1631
|
+
const sessionGrid = document.getElementById("sessionGrid");
|
|
1632
|
+
const sessionMeta = document.getElementById("sessionMeta");
|
|
1633
|
+
if (!sessionGrid || !sessionMeta) return;
|
|
1634
|
+
sessionGrid.textContent = "";
|
|
1635
|
+
const usagePayload = state.lastUsagePayload || {};
|
|
1636
|
+
const project = state.currentProject;
|
|
1637
|
+
usagePayload?.totals_global || usagePayload?.totals;
|
|
1638
|
+
const totalsFiltered = usagePayload?.totals_filtered || null;
|
|
1639
|
+
const isFiltered = !!(project && totalsFiltered);
|
|
1640
|
+
const packEvent = (Array.isArray(usagePayload?.events) ? usagePayload.events : []).find((e) => e?.event === "pack") || null;
|
|
1641
|
+
const recentPacks = Array.isArray(usagePayload?.recent_packs) ? usagePayload.recent_packs : [];
|
|
1642
|
+
const latestPack = recentPacks.length ? recentPacks[0] : null;
|
|
1643
|
+
const latestPackMeta = latestPack?.metadata_json || {};
|
|
1644
|
+
const lastPackAt = latestPack?.created_at || "";
|
|
1645
|
+
const packCount = Number(packEvent?.count || 0);
|
|
1646
|
+
const packTokens = Number(latestPack?.tokens_read || 0);
|
|
1647
|
+
const savedTokens = Number(latestPack?.tokens_saved || 0);
|
|
1648
|
+
const dedupedCount = Number(latestPackMeta?.exact_duplicates_collapsed || 0);
|
|
1649
|
+
const dedupeEnabled = !!latestPackMeta?.exact_dedupe_enabled;
|
|
1650
|
+
const reductionPercent = formatReductionPercent(savedTokens, packTokens);
|
|
1651
|
+
const packLine = packCount ? `${packCount} packs` : "No packs yet";
|
|
1652
|
+
const lastPackLine = lastPackAt ? `Last pack: ${formatTimestamp(lastPackAt)}` : "";
|
|
1653
|
+
sessionMeta.textContent = [
|
|
1654
|
+
isFiltered ? "Project" : "All projects",
|
|
1655
|
+
packLine,
|
|
1656
|
+
lastPackLine
|
|
1657
|
+
].filter(Boolean).join(" · ");
|
|
1658
|
+
[
|
|
1659
|
+
{
|
|
1660
|
+
label: "Last pack savings",
|
|
1661
|
+
value: latestPack ? `${savedTokens.toLocaleString()} (${reductionPercent})` : "n/a",
|
|
1662
|
+
icon: "trending-up"
|
|
1663
|
+
},
|
|
1664
|
+
{
|
|
1665
|
+
label: "Last pack size",
|
|
1666
|
+
value: latestPack ? packTokens.toLocaleString() : "n/a",
|
|
1667
|
+
icon: "package"
|
|
1668
|
+
},
|
|
1669
|
+
{
|
|
1670
|
+
label: "Last pack deduped",
|
|
1671
|
+
value: latestPack ? dedupedCount.toLocaleString() : "n/a",
|
|
1672
|
+
icon: "copy-check"
|
|
1673
|
+
},
|
|
1674
|
+
{
|
|
1675
|
+
label: "Exact dedupe",
|
|
1676
|
+
value: latestPack ? dedupeEnabled ? "On" : "Off" : "n/a",
|
|
1677
|
+
icon: "shield-check"
|
|
1678
|
+
},
|
|
1679
|
+
{
|
|
1680
|
+
label: "Packs",
|
|
1681
|
+
value: packCount || 0,
|
|
1682
|
+
icon: "archive"
|
|
1683
|
+
}
|
|
1684
|
+
].forEach((item) => {
|
|
1685
|
+
const block = el("div", "stat");
|
|
1686
|
+
const icon = document.createElement("i");
|
|
1687
|
+
icon.setAttribute("data-lucide", item.icon);
|
|
1688
|
+
icon.className = "stat-icon";
|
|
1689
|
+
const displayValue = typeof item.value === "number" ? item.value.toLocaleString() : item.value == null ? "n/a" : String(item.value);
|
|
1690
|
+
const content = el("div", "stat-content");
|
|
1691
|
+
content.append(el("div", "value", displayValue), el("div", "label", item.label));
|
|
1692
|
+
block.append(icon, content);
|
|
1693
|
+
sessionGrid.appendChild(block);
|
|
1694
|
+
});
|
|
1695
|
+
if (typeof globalThis.lucide !== "undefined") globalThis.lucide.createIcons();
|
|
1696
|
+
}
|
|
1697
|
+
async function loadHealthData() {
|
|
1698
|
+
const previousActorId = state.lastStatsPayload?.identity?.actor_id || null;
|
|
1699
|
+
const [statsPayload, usagePayload, sessionsPayload, rawEventsPayload] = await Promise.all([
|
|
1700
|
+
loadStats(),
|
|
1701
|
+
loadUsage(state.currentProject),
|
|
1702
|
+
loadSession(state.currentProject),
|
|
1703
|
+
loadRawEvents(state.currentProject)
|
|
1704
|
+
]);
|
|
1705
|
+
state.lastStatsPayload = statsPayload || {};
|
|
1706
|
+
state.lastUsagePayload = usagePayload || {};
|
|
1707
|
+
state.lastRawEventsPayload = rawEventsPayload || {};
|
|
1708
|
+
const nextActorId = state.lastStatsPayload?.identity?.actor_id || null;
|
|
1709
|
+
renderStats();
|
|
1710
|
+
renderSessionSummary();
|
|
1711
|
+
renderHealthOverview();
|
|
1712
|
+
if (state.activeTab === "feed" && previousActorId !== nextActorId) updateFeedView(true);
|
|
1713
|
+
}
|
|
1714
|
+
//#endregion
|
|
1715
|
+
//#region src/tabs/sync/helpers.ts
|
|
1716
|
+
function hideSkeleton(id) {
|
|
1717
|
+
const skeleton = document.getElementById(id);
|
|
1718
|
+
if (skeleton) skeleton.remove();
|
|
1719
|
+
}
|
|
1720
|
+
var adminSetupExpanded = false;
|
|
1721
|
+
function setAdminSetupExpanded(v) {
|
|
1722
|
+
adminSetupExpanded = v;
|
|
1723
|
+
}
|
|
1724
|
+
var teamInvitePanelOpen = false;
|
|
1725
|
+
function setTeamInvitePanelOpen(v) {
|
|
1726
|
+
teamInvitePanelOpen = v;
|
|
1727
|
+
}
|
|
1728
|
+
var openPeerScopeEditors = /* @__PURE__ */ new Set();
|
|
1729
|
+
/** Redact the last two octets of IPv4 addresses. */
|
|
1730
|
+
function redactIpOctets(text) {
|
|
1731
|
+
return text.replace(/\b(\d{1,3}\.\d{1,3})\.\d{1,3}\.\d{1,3}\b/g, "$1.#.#");
|
|
1732
|
+
}
|
|
1733
|
+
function redactAddress(address) {
|
|
1734
|
+
const raw = String(address || "");
|
|
1735
|
+
if (!raw) return "";
|
|
1736
|
+
return redactIpOctets(raw);
|
|
1737
|
+
}
|
|
1738
|
+
function pickPrimaryAddress(addresses) {
|
|
1739
|
+
if (!Array.isArray(addresses)) return "";
|
|
1740
|
+
const unique = Array.from(new Set(addresses.filter(Boolean)));
|
|
1741
|
+
return typeof unique[0] === "string" ? unique[0] : "";
|
|
1742
|
+
}
|
|
1743
|
+
function parseScopeList(value) {
|
|
1744
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1745
|
+
}
|
|
1746
|
+
function actorLabel(actor) {
|
|
1747
|
+
if (!actor || typeof actor !== "object") return "Unknown actor";
|
|
1748
|
+
const displayName = String(actor.display_name || "").trim();
|
|
1749
|
+
if (!displayName) return String(actor.actor_id || "Unknown actor");
|
|
1750
|
+
return displayName;
|
|
1751
|
+
}
|
|
1752
|
+
function assignedActorCount(actorId) {
|
|
1753
|
+
return (Array.isArray(state.lastSyncPeers) ? state.lastSyncPeers : []).filter((peer) => String(peer?.actor_id || "") === actorId).length;
|
|
1754
|
+
}
|
|
1755
|
+
function assignmentNote(actorId) {
|
|
1756
|
+
if (!actorId) return "Unassigned devices keep legacy fallback attribution until you choose an actor.";
|
|
1757
|
+
if ((Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).find((item) => String(item?.actor_id || "") === actorId)?.is_local) return "Local actor assignment keeps this device in your same-person continuity path, including private sync.";
|
|
1758
|
+
return "This actor receives memories from allowed projects by default. Use Only me on a memory when it should stay local.";
|
|
1759
|
+
}
|
|
1760
|
+
function buildActorOptions(selectedActorId) {
|
|
1761
|
+
const options = [];
|
|
1762
|
+
const unassigned = document.createElement("option");
|
|
1763
|
+
unassigned.value = "";
|
|
1764
|
+
unassigned.textContent = "No actor assigned";
|
|
1765
|
+
options.push(unassigned);
|
|
1766
|
+
(Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).forEach((actor) => {
|
|
1767
|
+
const option = document.createElement("option");
|
|
1768
|
+
option.value = String(actor.actor_id || "");
|
|
1769
|
+
option.textContent = actor.is_local ? `${actorLabel(actor)} (local)` : actorLabel(actor);
|
|
1770
|
+
option.selected = option.value === selectedActorId;
|
|
1771
|
+
options.push(option);
|
|
1772
|
+
});
|
|
1773
|
+
if (!selectedActorId) options[0].selected = true;
|
|
1774
|
+
return options;
|
|
1775
|
+
}
|
|
1776
|
+
function mergeTargetActors(actorId) {
|
|
1777
|
+
return (Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).filter((actor) => String(actor?.actor_id || "") !== actorId);
|
|
1778
|
+
}
|
|
1779
|
+
function actorMergeNote(targetActorId, secondaryActorId) {
|
|
1780
|
+
const target = mergeTargetActors(secondaryActorId).find((actor) => String(actor?.actor_id || "") === targetActorId);
|
|
1781
|
+
if (!targetActorId || !target) return "Choose where this duplicate actor should collapse.";
|
|
1782
|
+
return `Merge into ${actorLabel(target)}. Assigned devices move now; existing memories keep their current provenance.`;
|
|
1783
|
+
}
|
|
1784
|
+
function createChipEditor(initialValues, placeholder, emptyLabel) {
|
|
1785
|
+
let values = [...initialValues];
|
|
1786
|
+
const container = el("div", "peer-scope-editor");
|
|
1787
|
+
const chips = el("div", "peer-scope-chips");
|
|
1788
|
+
const input = el("input", "peer-scope-input");
|
|
1789
|
+
input.placeholder = placeholder;
|
|
1790
|
+
const syncChips = () => {
|
|
1791
|
+
chips.textContent = "";
|
|
1792
|
+
if (!values.length) {
|
|
1793
|
+
chips.appendChild(el("span", "peer-scope-chip empty", emptyLabel));
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
values.forEach((value, index) => {
|
|
1797
|
+
const chip = el("span", "peer-scope-chip");
|
|
1798
|
+
const label = el("span", null, value);
|
|
1799
|
+
const remove = el("button", "peer-scope-chip-remove", "x");
|
|
1800
|
+
remove.type = "button";
|
|
1801
|
+
remove.setAttribute("aria-label", `Remove ${value}`);
|
|
1802
|
+
remove.addEventListener("click", () => {
|
|
1803
|
+
values = values.filter((_, currentIndex) => currentIndex !== index);
|
|
1804
|
+
syncChips();
|
|
1805
|
+
});
|
|
1806
|
+
chip.append(label, remove);
|
|
1807
|
+
chips.appendChild(chip);
|
|
1808
|
+
});
|
|
1809
|
+
};
|
|
1810
|
+
const commitInput = () => {
|
|
1811
|
+
const incoming = parseScopeList(input.value);
|
|
1812
|
+
if (incoming.length) {
|
|
1813
|
+
values = Array.from(new Set([...values, ...incoming]));
|
|
1814
|
+
input.value = "";
|
|
1815
|
+
syncChips();
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
input.addEventListener("keydown", (event) => {
|
|
1819
|
+
if (event.key === "Enter" || event.key === ",") {
|
|
1820
|
+
event.preventDefault();
|
|
1821
|
+
commitInput();
|
|
1822
|
+
}
|
|
1823
|
+
if (event.key === "Backspace" && !input.value && values.length) {
|
|
1824
|
+
values = values.slice(0, -1);
|
|
1825
|
+
syncChips();
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
input.addEventListener("blur", commitInput);
|
|
1829
|
+
syncChips();
|
|
1830
|
+
container.append(chips, input);
|
|
1831
|
+
return {
|
|
1832
|
+
element: container,
|
|
1833
|
+
values: () => [...values]
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
function renderActionList(container, actions) {
|
|
1837
|
+
if (!container) return;
|
|
1838
|
+
container.textContent = "";
|
|
1839
|
+
if (!actions.length) {
|
|
1840
|
+
container.hidden = true;
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
container.hidden = false;
|
|
1844
|
+
actions.slice(0, 2).forEach((item) => {
|
|
1845
|
+
const row = el("div", "sync-action");
|
|
1846
|
+
const textWrap = el("div", "sync-action-text");
|
|
1847
|
+
textWrap.textContent = item.label;
|
|
1848
|
+
textWrap.appendChild(el("span", "sync-action-command", item.command));
|
|
1849
|
+
const btn = el("button", "settings-button sync-action-copy", "Copy");
|
|
1850
|
+
btn.addEventListener("click", () => copyToClipboard(item.command, btn));
|
|
1851
|
+
row.append(textWrap, btn);
|
|
1852
|
+
container.appendChild(row);
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
//#endregion
|
|
1856
|
+
//#region src/tabs/sync/diagnostics.ts
|
|
1857
|
+
var _renderSyncPeers = () => {};
|
|
1858
|
+
function setRenderSyncPeers(fn) {
|
|
1859
|
+
_renderSyncPeers = fn;
|
|
1860
|
+
}
|
|
1861
|
+
function renderSyncStatus() {
|
|
1862
|
+
const syncStatusGrid = document.getElementById("syncStatusGrid");
|
|
1863
|
+
const syncMeta = document.getElementById("syncMeta");
|
|
1864
|
+
const syncActions = document.getElementById("syncActions");
|
|
1865
|
+
if (!syncStatusGrid) return;
|
|
1866
|
+
hideSkeleton("syncDiagSkeleton");
|
|
1867
|
+
syncStatusGrid.textContent = "";
|
|
1868
|
+
const status = state.lastSyncStatus;
|
|
1869
|
+
if (!status) {
|
|
1870
|
+
renderActionList(syncActions, []);
|
|
1871
|
+
if (syncMeta) syncMeta.textContent = "Loading sync status…";
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
const peers = status.peers || {};
|
|
1875
|
+
const pingPayload = status.ping || {};
|
|
1876
|
+
const syncPayload = status.sync || {};
|
|
1877
|
+
const lastSync = status.last_sync_at || status.last_sync_at_utc || null;
|
|
1878
|
+
const lastPing = pingPayload.last_ping_at || status.last_ping_at || null;
|
|
1879
|
+
const syncError = status.last_sync_error || "";
|
|
1880
|
+
const pingError = status.last_ping_error || "";
|
|
1881
|
+
const pending = Number(status.pending || 0);
|
|
1882
|
+
const daemonDetail = String(status.daemon_detail || "");
|
|
1883
|
+
const daemonState = String(status.daemon_state || "unknown");
|
|
1884
|
+
const daemonStateLabel = daemonState === "offline-peers" ? "Offline peers" : titleCase(daemonState);
|
|
1885
|
+
const syncDisabled = daemonState === "disabled" || status.enabled === false;
|
|
1886
|
+
const peerCount = Object.keys(peers).length;
|
|
1887
|
+
const syncNoPeers = !syncDisabled && peerCount === 0;
|
|
1888
|
+
if (syncMeta) {
|
|
1889
|
+
const parts = syncDisabled ? ["State: Disabled", "Sync is optional and currently off"] : syncNoPeers ? ["State: No peers", "Add peers to enable replication"] : [
|
|
1890
|
+
`State: ${daemonStateLabel}`,
|
|
1891
|
+
`Peers: ${peerCount}`,
|
|
1892
|
+
lastSync ? `Last sync: ${formatAgeShort(secondsSince(lastSync))} ago` : "Last sync: never"
|
|
1893
|
+
];
|
|
1894
|
+
if (daemonState === "offline-peers") parts.push("All peers are currently offline; sync will resume automatically");
|
|
1895
|
+
if (daemonDetail && daemonState === "stopped") parts.push(`Detail: ${daemonDetail}`);
|
|
1896
|
+
syncMeta.textContent = parts.join(" · ");
|
|
1897
|
+
}
|
|
1898
|
+
(syncDisabled ? [
|
|
1899
|
+
{
|
|
1900
|
+
label: "State",
|
|
1901
|
+
value: "Disabled"
|
|
1902
|
+
},
|
|
1903
|
+
{
|
|
1904
|
+
label: "Mode",
|
|
1905
|
+
value: "Optional"
|
|
1906
|
+
},
|
|
1907
|
+
{
|
|
1908
|
+
label: "Pending events",
|
|
1909
|
+
value: pending
|
|
1910
|
+
},
|
|
1911
|
+
{
|
|
1912
|
+
label: "Last sync",
|
|
1913
|
+
value: "n/a"
|
|
1914
|
+
}
|
|
1915
|
+
] : syncNoPeers ? [
|
|
1916
|
+
{
|
|
1917
|
+
label: "State",
|
|
1918
|
+
value: "No peers"
|
|
1919
|
+
},
|
|
1920
|
+
{
|
|
1921
|
+
label: "Mode",
|
|
1922
|
+
value: "Idle"
|
|
1923
|
+
},
|
|
1924
|
+
{
|
|
1925
|
+
label: "Pending events",
|
|
1926
|
+
value: pending
|
|
1927
|
+
},
|
|
1928
|
+
{
|
|
1929
|
+
label: "Last sync",
|
|
1930
|
+
value: "n/a"
|
|
1931
|
+
}
|
|
1932
|
+
] : [
|
|
1933
|
+
{
|
|
1934
|
+
label: "State",
|
|
1935
|
+
value: daemonStateLabel
|
|
1936
|
+
},
|
|
1937
|
+
{
|
|
1938
|
+
label: "Pending events",
|
|
1939
|
+
value: pending
|
|
1940
|
+
},
|
|
1941
|
+
{
|
|
1942
|
+
label: "Last sync",
|
|
1943
|
+
value: lastSync ? `${formatAgeShort(secondsSince(lastSync))} ago` : "never"
|
|
1944
|
+
},
|
|
1945
|
+
{
|
|
1946
|
+
label: "Last ping",
|
|
1947
|
+
value: lastPing ? `${formatAgeShort(secondsSince(lastPing))} ago` : "never"
|
|
1948
|
+
}
|
|
1949
|
+
]).forEach((item) => {
|
|
1950
|
+
const block = el("div", "stat");
|
|
1951
|
+
const content = el("div", "stat-content");
|
|
1952
|
+
content.append(el("div", "value", item.value), el("div", "label", item.label));
|
|
1953
|
+
block.appendChild(content);
|
|
1954
|
+
syncStatusGrid.appendChild(block);
|
|
1955
|
+
});
|
|
1956
|
+
if (!syncDisabled && !syncNoPeers && (syncError || pingError)) {
|
|
1957
|
+
const block = el("div", "stat");
|
|
1958
|
+
const content = el("div", "stat-content");
|
|
1959
|
+
content.append(el("div", "value", "Errors"), el("div", "label", [syncError, pingError].filter(Boolean).join(" · ")));
|
|
1960
|
+
block.appendChild(content);
|
|
1961
|
+
syncStatusGrid.appendChild(block);
|
|
1962
|
+
}
|
|
1963
|
+
if (!syncDisabled && !syncNoPeers && syncPayload?.seconds_since_last) {
|
|
1964
|
+
const block = el("div", "stat");
|
|
1965
|
+
const content = el("div", "stat-content");
|
|
1966
|
+
content.append(el("div", "value", `${syncPayload.seconds_since_last}s`), el("div", "label", "Since last sync"));
|
|
1967
|
+
block.appendChild(content);
|
|
1968
|
+
syncStatusGrid.appendChild(block);
|
|
1969
|
+
}
|
|
1970
|
+
if (!syncDisabled && !syncNoPeers && pingPayload?.seconds_since_last) {
|
|
1971
|
+
const block = el("div", "stat");
|
|
1972
|
+
const content = el("div", "stat-content");
|
|
1973
|
+
content.append(el("div", "value", `${pingPayload.seconds_since_last}s`), el("div", "label", "Since last ping"));
|
|
1974
|
+
block.appendChild(content);
|
|
1975
|
+
syncStatusGrid.appendChild(block);
|
|
1976
|
+
}
|
|
1977
|
+
const actions = [];
|
|
1978
|
+
if (syncNoPeers) {} else if (daemonState === "offline-peers") {} else if (daemonState === "stopped") {
|
|
1979
|
+
actions.push({
|
|
1980
|
+
label: "Sync daemon is stopped. Start it.",
|
|
1981
|
+
command: "uv run codemem sync start"
|
|
1982
|
+
});
|
|
1983
|
+
actions.push({
|
|
1984
|
+
label: "Then run one immediate sync pass.",
|
|
1985
|
+
command: "uv run codemem sync once"
|
|
1986
|
+
});
|
|
1987
|
+
} else if (syncError || pingError || daemonState === "error") {
|
|
1988
|
+
actions.push({
|
|
1989
|
+
label: "Sync reports errors. Restart now.",
|
|
1990
|
+
command: "uv run codemem sync restart && uv run codemem sync once"
|
|
1991
|
+
});
|
|
1992
|
+
actions.push({
|
|
1993
|
+
label: "Then run doctor for root cause.",
|
|
1994
|
+
command: "uv run codemem sync doctor"
|
|
1995
|
+
});
|
|
1996
|
+
} else if (!syncDisabled && !syncNoPeers && pending > 0) actions.push({
|
|
1997
|
+
label: "Pending sync work detected. Run one pass now.",
|
|
1998
|
+
command: "uv run codemem sync once"
|
|
1999
|
+
});
|
|
2000
|
+
renderActionList(syncActions, actions);
|
|
2001
|
+
}
|
|
2002
|
+
function renderSyncAttempts() {
|
|
2003
|
+
const syncAttempts = document.getElementById("syncAttempts");
|
|
2004
|
+
if (!syncAttempts) return;
|
|
2005
|
+
syncAttempts.textContent = "";
|
|
2006
|
+
const attempts = state.lastSyncAttempts;
|
|
2007
|
+
if (!Array.isArray(attempts) || !attempts.length) return;
|
|
2008
|
+
attempts.slice(0, 5).forEach((attempt) => {
|
|
2009
|
+
const line = el("div", "diag-line");
|
|
2010
|
+
const left = el("div", "left");
|
|
2011
|
+
left.append(el("div", null, attempt.status || "unknown"), el("div", "small", isSyncRedactionEnabled() ? redactAddress(attempt.address) : attempt.address || "n/a"));
|
|
2012
|
+
const right = el("div", "right");
|
|
2013
|
+
const time = attempt.started_at || attempt.started_at_utc || "";
|
|
2014
|
+
right.textContent = time ? formatTimestamp(time) : "";
|
|
2015
|
+
line.append(left, right);
|
|
2016
|
+
syncAttempts.appendChild(line);
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
function renderPairing() {
|
|
2020
|
+
const pairingPayloadEl = document.getElementById("pairingPayload");
|
|
2021
|
+
const pairingHint = document.getElementById("pairingHint");
|
|
2022
|
+
if (!pairingPayloadEl) return;
|
|
2023
|
+
const payload = state.pairingPayloadRaw;
|
|
2024
|
+
if (!payload || typeof payload !== "object") {
|
|
2025
|
+
pairingPayloadEl.textContent = "Pairing not available";
|
|
2026
|
+
if (pairingHint) pairingHint.textContent = "Enable sync and retry.";
|
|
2027
|
+
state.pairingCommandRaw = "";
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
if (payload.redacted) {
|
|
2031
|
+
pairingPayloadEl.textContent = "Pairing payload hidden";
|
|
2032
|
+
if (pairingHint) pairingHint.textContent = "Diagnostics are required to view the pairing payload.";
|
|
2033
|
+
state.pairingCommandRaw = "";
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
const safePayload = {
|
|
2037
|
+
...payload,
|
|
2038
|
+
addresses: Array.isArray(payload.addresses) ? payload.addresses : []
|
|
2039
|
+
};
|
|
2040
|
+
const compact = JSON.stringify(safePayload);
|
|
2041
|
+
const command = `echo '${btoa(compact)}' | base64 -d | codemem sync pair --accept-file -`;
|
|
2042
|
+
pairingPayloadEl.textContent = command;
|
|
2043
|
+
state.pairingCommandRaw = command;
|
|
2044
|
+
if (pairingHint) pairingHint.textContent = "Copy this command and run it on the other device. Use --include/--exclude to control which projects sync.";
|
|
2045
|
+
}
|
|
2046
|
+
function initDiagnosticsEvents(refreshCallback) {
|
|
2047
|
+
const syncPairingToggle = document.getElementById("syncPairingToggle");
|
|
2048
|
+
const syncRedact = document.getElementById("syncRedact");
|
|
2049
|
+
const pairingCopy = document.getElementById("pairingCopy");
|
|
2050
|
+
const syncPairing = document.getElementById("syncPairing");
|
|
2051
|
+
if (syncPairing) syncPairing.hidden = !state.syncPairingOpen;
|
|
2052
|
+
if (syncPairingToggle) {
|
|
2053
|
+
syncPairingToggle.textContent = state.syncPairingOpen ? "Hide pairing" : "Show pairing";
|
|
2054
|
+
syncPairingToggle.setAttribute("aria-expanded", String(state.syncPairingOpen));
|
|
2055
|
+
}
|
|
2056
|
+
if (syncRedact) syncRedact.checked = isSyncRedactionEnabled();
|
|
2057
|
+
syncPairingToggle?.addEventListener("click", () => {
|
|
2058
|
+
const next = !state.syncPairingOpen;
|
|
2059
|
+
setSyncPairingOpen(next);
|
|
2060
|
+
if (syncPairing) syncPairing.hidden = !next;
|
|
2061
|
+
if (syncPairingToggle) {
|
|
2062
|
+
syncPairingToggle.textContent = next ? "Hide pairing" : "Show pairing";
|
|
2063
|
+
syncPairingToggle.setAttribute("aria-expanded", String(next));
|
|
2064
|
+
}
|
|
2065
|
+
if (next) {
|
|
2066
|
+
const pairingPayloadEl = document.getElementById("pairingPayload");
|
|
2067
|
+
const pairingHint = document.getElementById("pairingHint");
|
|
2068
|
+
if (pairingPayloadEl) pairingPayloadEl.textContent = "Loading…";
|
|
2069
|
+
if (pairingHint) pairingHint.textContent = "Fetching pairing payload…";
|
|
2070
|
+
}
|
|
2071
|
+
refreshCallback();
|
|
2072
|
+
});
|
|
2073
|
+
syncRedact?.addEventListener("change", () => {
|
|
2074
|
+
setSyncRedactionEnabled(Boolean(syncRedact.checked));
|
|
2075
|
+
renderSyncStatus();
|
|
2076
|
+
_renderSyncPeers();
|
|
2077
|
+
renderSyncAttempts();
|
|
2078
|
+
renderPairing();
|
|
2079
|
+
});
|
|
2080
|
+
pairingCopy?.addEventListener("click", async () => {
|
|
2081
|
+
const text = state.pairingCommandRaw || document.getElementById("pairingPayload")?.textContent || "";
|
|
2082
|
+
if (text && pairingCopy) await copyToClipboard(text, pairingCopy);
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
//#endregion
|
|
2086
|
+
//#region src/lib/form.ts
|
|
2087
|
+
function shakeField(input) {
|
|
2088
|
+
input.classList.add("sync-shake");
|
|
2089
|
+
input.addEventListener("animationend", () => input.classList.remove("sync-shake"), { once: true });
|
|
2090
|
+
}
|
|
2091
|
+
function markFieldError(input, message) {
|
|
2092
|
+
input.classList.add("sync-field-error");
|
|
2093
|
+
const existing = input.parentElement?.querySelector(".sync-field-hint");
|
|
2094
|
+
if (existing) existing.remove();
|
|
2095
|
+
const hint = document.createElement("div");
|
|
2096
|
+
hint.className = "sync-field-hint";
|
|
2097
|
+
hint.textContent = message;
|
|
2098
|
+
input.insertAdjacentElement("afterend", hint);
|
|
2099
|
+
shakeField(input);
|
|
2100
|
+
input.addEventListener("input", () => clearFieldError(input), { once: true });
|
|
2101
|
+
return false;
|
|
2102
|
+
}
|
|
2103
|
+
function clearFieldError(input) {
|
|
2104
|
+
input.classList.remove("sync-field-error");
|
|
2105
|
+
const hint = input.parentElement?.querySelector(".sync-field-hint");
|
|
2106
|
+
if (hint) hint.remove();
|
|
2107
|
+
}
|
|
2108
|
+
function friendlyError(error, fallback) {
|
|
2109
|
+
if (error instanceof Error) {
|
|
2110
|
+
const msg = error.message;
|
|
2111
|
+
if (msg.includes("fetch") || msg.includes("network") || msg.includes("Failed to fetch")) return "Network error — check your connection and try again.";
|
|
2112
|
+
return msg;
|
|
2113
|
+
}
|
|
2114
|
+
return fallback;
|
|
2115
|
+
}
|
|
2116
|
+
//#endregion
|
|
2117
|
+
//#region src/tabs/sync/team-sync.ts
|
|
2118
|
+
function ensureInvitePanelInAdminSection() {
|
|
2119
|
+
const invitePanel = document.getElementById("syncInvitePanel");
|
|
2120
|
+
const adminSection = document.getElementById("syncAdminSection");
|
|
2121
|
+
if (!invitePanel || !adminSection) return;
|
|
2122
|
+
if (invitePanel.parentElement !== adminSection) adminSection.appendChild(invitePanel);
|
|
2123
|
+
}
|
|
2124
|
+
function ensureJoinPanelInSetupSection() {
|
|
2125
|
+
const joinPanel = document.getElementById("syncJoinPanel");
|
|
2126
|
+
const joinSection = document.getElementById("syncJoinSection");
|
|
2127
|
+
if (!joinPanel || !joinSection) return;
|
|
2128
|
+
if (joinPanel.parentElement !== joinSection) joinSection.appendChild(joinPanel);
|
|
2129
|
+
}
|
|
2130
|
+
function setInviteOutputVisibility() {
|
|
2131
|
+
const syncInviteOutput = document.getElementById("syncInviteOutput");
|
|
2132
|
+
if (!syncInviteOutput) return;
|
|
2133
|
+
const encoded = String(state.lastTeamInvite?.encoded || "").trim();
|
|
2134
|
+
syncInviteOutput.value = encoded;
|
|
2135
|
+
syncInviteOutput.hidden = !encoded;
|
|
2136
|
+
}
|
|
2137
|
+
function openFeedSharingReview() {
|
|
2138
|
+
setFeedScopeFilter("mine");
|
|
2139
|
+
state.feedQuery = "";
|
|
2140
|
+
window.location.hash = "feed";
|
|
2141
|
+
}
|
|
2142
|
+
function renderSyncSharingReview() {
|
|
2143
|
+
const panel = document.getElementById("syncSharingReview");
|
|
2144
|
+
const meta = document.getElementById("syncSharingReviewMeta");
|
|
2145
|
+
const list = document.getElementById("syncSharingReviewList");
|
|
2146
|
+
if (!panel || !meta || !list) return;
|
|
2147
|
+
list.textContent = "";
|
|
2148
|
+
const items = Array.isArray(state.lastSyncSharingReview) ? state.lastSyncSharingReview : [];
|
|
2149
|
+
if (!items.length) {
|
|
2150
|
+
panel.hidden = true;
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
panel.hidden = false;
|
|
2154
|
+
meta.textContent = `Teammates receive memories from ${state.currentProject ? `current project (${state.currentProject})` : "all allowed projects"} by default. Use Only me on a memory when it should stay local.`;
|
|
2155
|
+
items.forEach((item) => {
|
|
2156
|
+
const row = el("div", "actor-row");
|
|
2157
|
+
const details = el("div", "actor-details");
|
|
2158
|
+
const title = el("div", "actor-title");
|
|
2159
|
+
title.append(el("strong", null, String(item.peer_name || item.peer_device_id || "Device")), el("span", "badge actor-badge", `actor: ${String(item.actor_display_name || item.actor_id || "unknown")}`));
|
|
2160
|
+
const note = el("div", "peer-meta", `${Number(item.shareable_count || 0)} share by default \u00b7 ${Number(item.private_count || 0)} marked Only me \u00b7 ${String(item.scope_label || "All allowed projects")}`);
|
|
2161
|
+
details.append(title, note);
|
|
2162
|
+
const actions = el("div", "actor-actions");
|
|
2163
|
+
const reviewBtn = el("button", "settings-button", "Review my memories in Feed");
|
|
2164
|
+
reviewBtn.addEventListener("click", () => openFeedSharingReview());
|
|
2165
|
+
actions.appendChild(reviewBtn);
|
|
2166
|
+
row.append(details, actions);
|
|
2167
|
+
list.appendChild(row);
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
var _loadSyncData$1 = async () => {};
|
|
2171
|
+
function setLoadSyncData$1(fn) {
|
|
2172
|
+
_loadSyncData$1 = fn;
|
|
2173
|
+
}
|
|
2174
|
+
function renderTeamSync() {
|
|
2175
|
+
const meta = document.getElementById("syncTeamMeta");
|
|
2176
|
+
const setupPanel = document.getElementById("syncSetupPanel");
|
|
2177
|
+
const list = document.getElementById("syncTeamStatus");
|
|
2178
|
+
const actions = document.getElementById("syncTeamActions");
|
|
2179
|
+
const invitePanel = document.getElementById("syncInvitePanel");
|
|
2180
|
+
const toggleAdmin = document.getElementById("syncToggleAdmin");
|
|
2181
|
+
const joinPanel = document.getElementById("syncJoinPanel");
|
|
2182
|
+
const joinRequests = document.getElementById("syncJoinRequests");
|
|
2183
|
+
if (!meta || !setupPanel || !list || !actions) return;
|
|
2184
|
+
hideSkeleton("syncTeamSkeleton");
|
|
2185
|
+
ensureInvitePanelInAdminSection();
|
|
2186
|
+
ensureJoinPanelInSetupSection();
|
|
2187
|
+
list.textContent = "";
|
|
2188
|
+
actions.textContent = "";
|
|
2189
|
+
if (joinRequests) joinRequests.textContent = "";
|
|
2190
|
+
setInviteOutputVisibility();
|
|
2191
|
+
const coordinator = state.lastSyncCoordinator;
|
|
2192
|
+
const configured = Boolean(coordinator && coordinator.configured);
|
|
2193
|
+
meta.textContent = configured ? `Connected to ${String(coordinator.coordinator_url || "")} \u00b7 group: ${(coordinator.groups || []).join(", ") || "none"}` : "Create a team invite or join an existing team to start syncing memories with teammates.";
|
|
2194
|
+
if (!configured) {
|
|
2195
|
+
setupPanel.hidden = false;
|
|
2196
|
+
list.hidden = true;
|
|
2197
|
+
actions.hidden = true;
|
|
2198
|
+
if (joinRequests) joinRequests.hidden = true;
|
|
2199
|
+
if (invitePanel) invitePanel.hidden = !adminSetupExpanded;
|
|
2200
|
+
if (toggleAdmin) toggleAdmin.textContent = adminSetupExpanded ? "Hide team setup" : "Set up a new team instead…";
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
setupPanel.hidden = true;
|
|
2204
|
+
list.hidden = false;
|
|
2205
|
+
actions.hidden = false;
|
|
2206
|
+
if (joinRequests) joinRequests.hidden = false;
|
|
2207
|
+
const presenceLabel = coordinator.presence_status === "posted" ? "Connected" : coordinator.presence_status === "not_enrolled" ? "Not connected — import an invite or ask your admin to enroll this device" : "Connection error";
|
|
2208
|
+
const statusRow = el("div", "sync-team-summary");
|
|
2209
|
+
const statusLine = el("div", "sync-team-status-row");
|
|
2210
|
+
const statusLabel = el("span", "sync-team-status-label", "Status");
|
|
2211
|
+
const statusBadge = el("span", `pill ${coordinator.presence_status === "posted" ? "pill-success" : coordinator.presence_status === "not_enrolled" ? "pill-warning" : "pill-error"}`, presenceLabel);
|
|
2212
|
+
const metricParts = [`Paired devices: ${Number(coordinator.paired_peer_count || 0)}`, `Discovered: ${Number(coordinator.fresh_peer_count || 0)}`];
|
|
2213
|
+
if (Number(coordinator.stale_peer_count || 0) > 0) metricParts.push(`Inactive: ${Number(coordinator.stale_peer_count || 0)}`);
|
|
2214
|
+
statusLine.append(statusLabel, statusBadge);
|
|
2215
|
+
statusRow.append(statusLine, el("div", "sync-team-metrics", metricParts.join(" · ")));
|
|
2216
|
+
list.appendChild(statusRow);
|
|
2217
|
+
const inviteToggleRow = el("div", "sync-action");
|
|
2218
|
+
const inviteToggleText = el("div", "sync-action-text");
|
|
2219
|
+
inviteToggleText.textContent = "Generate an invite to add another teammate to this team.";
|
|
2220
|
+
const inviteToggleBtn = el("button", "settings-button", "Invite a teammate");
|
|
2221
|
+
inviteToggleBtn.addEventListener("click", () => {
|
|
2222
|
+
if (!invitePanel) return;
|
|
2223
|
+
setTeamInvitePanelOpen(!teamInvitePanelOpen);
|
|
2224
|
+
if (invitePanel.parentElement !== actions) actions.appendChild(invitePanel);
|
|
2225
|
+
invitePanel.hidden = !teamInvitePanelOpen;
|
|
2226
|
+
inviteToggleBtn.textContent = teamInvitePanelOpen ? "Hide invite form" : "Invite a teammate";
|
|
2227
|
+
});
|
|
2228
|
+
inviteToggleRow.append(inviteToggleText, inviteToggleBtn);
|
|
2229
|
+
actions.appendChild(inviteToggleRow);
|
|
2230
|
+
if (invitePanel) if (teamInvitePanelOpen) {
|
|
2231
|
+
if (invitePanel.parentElement !== actions) actions.appendChild(invitePanel);
|
|
2232
|
+
invitePanel.hidden = false;
|
|
2233
|
+
inviteToggleBtn.textContent = "Hide invite form";
|
|
2234
|
+
} else invitePanel.hidden = true;
|
|
2235
|
+
if (coordinator.presence_status === "not_enrolled") {
|
|
2236
|
+
if (joinPanel) {
|
|
2237
|
+
if (joinPanel.parentElement !== actions) actions.appendChild(joinPanel);
|
|
2238
|
+
joinPanel.hidden = false;
|
|
2239
|
+
}
|
|
2240
|
+
const row = el("div", "sync-action");
|
|
2241
|
+
const textWrap = el("div", "sync-action-text");
|
|
2242
|
+
textWrap.textContent = "This device is not connected to the team yet.";
|
|
2243
|
+
textWrap.appendChild(el("span", "sync-action-command", "Import a team invite or ask your admin to enroll this device"));
|
|
2244
|
+
actions.appendChild(row);
|
|
2245
|
+
row.appendChild(textWrap);
|
|
2246
|
+
}
|
|
2247
|
+
if (!Number(coordinator.paired_peer_count || 0) && coordinator.presence_status === "posted") {
|
|
2248
|
+
const row = el("div", "sync-action");
|
|
2249
|
+
const textWrap = el("div", "sync-action-text");
|
|
2250
|
+
textWrap.textContent = "No devices are paired yet.";
|
|
2251
|
+
textWrap.appendChild(el("span", "sync-action-command", "uv run codemem sync pair --payload-only"));
|
|
2252
|
+
const btn = el("button", "settings-button sync-action-copy", "Copy");
|
|
2253
|
+
btn.addEventListener("click", () => copyToClipboard("uv run codemem sync pair --payload-only", btn));
|
|
2254
|
+
row.append(textWrap, btn);
|
|
2255
|
+
actions.appendChild(row);
|
|
2256
|
+
}
|
|
2257
|
+
const pending = Array.isArray(state.lastSyncJoinRequests) ? state.lastSyncJoinRequests : [];
|
|
2258
|
+
if (joinRequests && pending.length) {
|
|
2259
|
+
const title = el("div", "peer-meta", `${pending.length} pending join request${pending.length === 1 ? "" : "s"}`);
|
|
2260
|
+
joinRequests.appendChild(title);
|
|
2261
|
+
pending.forEach((request) => {
|
|
2262
|
+
const row = el("div", "actor-row");
|
|
2263
|
+
const details = el("div", "actor-details");
|
|
2264
|
+
const name = String(request.display_name || request.device_id || "Pending device");
|
|
2265
|
+
details.append(el("div", "actor-title", name), el("div", "peer-meta", `request: ${String(request.request_id || "")}`));
|
|
2266
|
+
const rowActions = el("div", "actor-actions");
|
|
2267
|
+
const approveBtn = el("button", "settings-button", "Approve");
|
|
2268
|
+
const denyBtn = el("button", "settings-button", "Deny");
|
|
2269
|
+
approveBtn.addEventListener("click", async () => {
|
|
2270
|
+
approveBtn.disabled = true;
|
|
2271
|
+
denyBtn.disabled = true;
|
|
2272
|
+
approveBtn.textContent = "Approving…";
|
|
2273
|
+
try {
|
|
2274
|
+
await reviewJoinRequest(String(request.request_id || ""), "approve");
|
|
2275
|
+
showGlobalNotice(`Approved ${name}. They can now sync with the team.`);
|
|
2276
|
+
await _loadSyncData$1();
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
showGlobalNotice(friendlyError(error, "Failed to approve join request."), "warning");
|
|
2279
|
+
approveBtn.textContent = "Retry";
|
|
2280
|
+
} finally {
|
|
2281
|
+
approveBtn.disabled = false;
|
|
2282
|
+
denyBtn.disabled = false;
|
|
2283
|
+
}
|
|
2284
|
+
});
|
|
2285
|
+
denyBtn.addEventListener("click", async () => {
|
|
2286
|
+
if (!window.confirm(`Deny join request from ${name}? They will need a new invite to try again.`)) return;
|
|
2287
|
+
approveBtn.disabled = true;
|
|
2288
|
+
denyBtn.disabled = true;
|
|
2289
|
+
denyBtn.textContent = "Denying…";
|
|
2290
|
+
try {
|
|
2291
|
+
await reviewJoinRequest(String(request.request_id || ""), "deny");
|
|
2292
|
+
showGlobalNotice(`Denied join request from ${name}.`);
|
|
2293
|
+
await _loadSyncData$1();
|
|
2294
|
+
} catch (error) {
|
|
2295
|
+
showGlobalNotice(friendlyError(error, "Failed to deny join request."), "warning");
|
|
2296
|
+
denyBtn.textContent = "Retry deny";
|
|
2297
|
+
} finally {
|
|
2298
|
+
approveBtn.disabled = false;
|
|
2299
|
+
denyBtn.disabled = false;
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2302
|
+
rowActions.append(approveBtn, denyBtn);
|
|
2303
|
+
row.append(details, rowActions);
|
|
2304
|
+
joinRequests.appendChild(row);
|
|
2305
|
+
});
|
|
2306
|
+
} else if (joinRequests) joinRequests.hidden = true;
|
|
2307
|
+
}
|
|
2308
|
+
function initTeamSyncEvents(refreshCallback, loadSyncData) {
|
|
2309
|
+
const syncNowButton = document.getElementById("syncNowButton");
|
|
2310
|
+
const syncToggleAdmin = document.getElementById("syncToggleAdmin");
|
|
2311
|
+
const syncInvitePanel = document.getElementById("syncInvitePanel");
|
|
2312
|
+
const syncCreateInviteButton = document.getElementById("syncCreateInviteButton");
|
|
2313
|
+
const syncInviteGroup = document.getElementById("syncInviteGroup");
|
|
2314
|
+
const syncInvitePolicy = document.getElementById("syncInvitePolicy");
|
|
2315
|
+
const syncInviteTtl = document.getElementById("syncInviteTtl");
|
|
2316
|
+
const syncInviteOutput = document.getElementById("syncInviteOutput");
|
|
2317
|
+
const syncJoinButton = document.getElementById("syncJoinButton");
|
|
2318
|
+
const syncJoinInvite = document.getElementById("syncJoinInvite");
|
|
2319
|
+
syncToggleAdmin?.addEventListener("click", () => {
|
|
2320
|
+
if (!syncInvitePanel) return;
|
|
2321
|
+
setAdminSetupExpanded(!adminSetupExpanded);
|
|
2322
|
+
syncInvitePanel.hidden = !adminSetupExpanded;
|
|
2323
|
+
syncToggleAdmin.setAttribute("aria-expanded", String(adminSetupExpanded));
|
|
2324
|
+
syncToggleAdmin.textContent = adminSetupExpanded ? "Hide team setup" : "Set up a new team instead…";
|
|
2325
|
+
});
|
|
2326
|
+
syncCreateInviteButton?.addEventListener("click", async () => {
|
|
2327
|
+
if (!syncCreateInviteButton || !syncInviteGroup || !syncInvitePolicy || !syncInviteTtl || !syncInviteOutput) return;
|
|
2328
|
+
const groupName = syncInviteGroup.value.trim();
|
|
2329
|
+
const ttlValue = Number(syncInviteTtl.value);
|
|
2330
|
+
let valid = true;
|
|
2331
|
+
if (!groupName) valid = markFieldError(syncInviteGroup, "Team name is required.");
|
|
2332
|
+
else clearFieldError(syncInviteGroup);
|
|
2333
|
+
if (!ttlValue || ttlValue < 1) valid = markFieldError(syncInviteTtl, "Must be at least 1 hour.");
|
|
2334
|
+
else clearFieldError(syncInviteTtl);
|
|
2335
|
+
if (!valid) return;
|
|
2336
|
+
syncCreateInviteButton.disabled = true;
|
|
2337
|
+
syncCreateInviteButton.textContent = "Creating…";
|
|
2338
|
+
try {
|
|
2339
|
+
const result = await createCoordinatorInvite({
|
|
2340
|
+
group_id: groupName,
|
|
2341
|
+
policy: syncInvitePolicy.value,
|
|
2342
|
+
ttl_hours: ttlValue || 24
|
|
2343
|
+
});
|
|
2344
|
+
state.lastTeamInvite = result;
|
|
2345
|
+
syncInviteOutput.value = String(result.encoded || "");
|
|
2346
|
+
syncInviteOutput.hidden = false;
|
|
2347
|
+
syncInviteOutput.focus();
|
|
2348
|
+
syncInviteOutput.select();
|
|
2349
|
+
showGlobalNotice("Invite created. Copy the text above and share it with your teammate.");
|
|
2350
|
+
} catch (error) {
|
|
2351
|
+
showGlobalNotice(friendlyError(error, "Failed to create invite."), "warning");
|
|
2352
|
+
} finally {
|
|
2353
|
+
syncCreateInviteButton.disabled = false;
|
|
2354
|
+
syncCreateInviteButton.textContent = "Create invite";
|
|
2355
|
+
}
|
|
2356
|
+
});
|
|
2357
|
+
syncJoinButton?.addEventListener("click", async () => {
|
|
2358
|
+
if (!syncJoinButton || !syncJoinInvite) return;
|
|
2359
|
+
const inviteValue = syncJoinInvite.value.trim();
|
|
2360
|
+
if (!inviteValue) {
|
|
2361
|
+
markFieldError(syncJoinInvite, "Paste a team invite to join.");
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
clearFieldError(syncJoinInvite);
|
|
2365
|
+
syncJoinButton.disabled = true;
|
|
2366
|
+
syncJoinButton.textContent = "Joining…";
|
|
2367
|
+
try {
|
|
2368
|
+
const result = await importCoordinatorInvite(inviteValue);
|
|
2369
|
+
state.lastTeamJoin = result;
|
|
2370
|
+
showGlobalNotice(result.status === "pending" ? "Join request submitted — waiting for admin approval." : "Joined team successfully.");
|
|
2371
|
+
syncJoinInvite.value = "";
|
|
2372
|
+
await loadSyncData();
|
|
2373
|
+
} catch (error) {
|
|
2374
|
+
showGlobalNotice(friendlyError(error, "Failed to import invite."), "warning");
|
|
2375
|
+
} finally {
|
|
2376
|
+
syncJoinButton.disabled = false;
|
|
2377
|
+
syncJoinButton.textContent = "Join team";
|
|
2378
|
+
}
|
|
2379
|
+
});
|
|
2380
|
+
syncNowButton?.addEventListener("click", async () => {
|
|
2381
|
+
if (!syncNowButton) return;
|
|
2382
|
+
syncNowButton.disabled = true;
|
|
2383
|
+
syncNowButton.textContent = "Syncing…";
|
|
2384
|
+
try {
|
|
2385
|
+
await triggerSync();
|
|
2386
|
+
showGlobalNotice("Sync pass started.");
|
|
2387
|
+
} catch (error) {
|
|
2388
|
+
showGlobalNotice(friendlyError(error, "Failed to start sync."), "warning");
|
|
2389
|
+
}
|
|
2390
|
+
syncNowButton.disabled = false;
|
|
2391
|
+
syncNowButton.textContent = "Sync now";
|
|
2392
|
+
refreshCallback();
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
//#endregion
|
|
2396
|
+
//#region src/tabs/sync/people.ts
|
|
2397
|
+
var _loadSyncData = async () => {};
|
|
2398
|
+
function setLoadSyncData(fn) {
|
|
2399
|
+
_loadSyncData = fn;
|
|
2400
|
+
}
|
|
2401
|
+
function renderSyncActors() {
|
|
2402
|
+
const actorList = document.getElementById("syncActorsList");
|
|
2403
|
+
const actorMeta = document.getElementById("syncActorsMeta");
|
|
2404
|
+
if (!actorList) return;
|
|
2405
|
+
hideSkeleton("syncActorsSkeleton");
|
|
2406
|
+
actorList.textContent = "";
|
|
2407
|
+
const actors = Array.isArray(state.lastSyncActors) ? state.lastSyncActors : [];
|
|
2408
|
+
if (actorMeta) actorMeta.textContent = actors.length ? "Create, rename, and merge actors here. Assign each device below. Non-local actors receive memories from allowed projects unless you mark them Only me." : "No named actors yet. Create one here, then assign devices below.";
|
|
2409
|
+
if (!actors.length) {
|
|
2410
|
+
actorList.appendChild(el("div", "sync-empty-state", "No actors yet. Create one to represent yourself or a teammate."));
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
actors.forEach((actor) => {
|
|
2414
|
+
const row = el("div", "actor-row");
|
|
2415
|
+
const details = el("div", "actor-details");
|
|
2416
|
+
const title = el("div", "actor-title");
|
|
2417
|
+
const name = el("strong", null, actorLabel(actor));
|
|
2418
|
+
const count = assignedActorCount(String(actor.actor_id || ""));
|
|
2419
|
+
const badge = el("span", `badge actor-badge${actor.is_local ? " local" : ""}`, actor.is_local ? "Local" : `${count} device${count === 1 ? "" : "s"}`);
|
|
2420
|
+
title.append(name, badge);
|
|
2421
|
+
const note = el("div", "peer-meta", actor.is_local ? "Used for this device and same-person devices." : `${count} assigned device${count === 1 ? "" : "s"}`);
|
|
2422
|
+
details.append(title, note);
|
|
2423
|
+
const actions = el("div", "actor-actions");
|
|
2424
|
+
if (actor.is_local) actions.appendChild(el("div", "peer-meta", "Rename in config"));
|
|
2425
|
+
else {
|
|
2426
|
+
const actorId = String(actor.actor_id || "");
|
|
2427
|
+
const input = document.createElement("input");
|
|
2428
|
+
input.className = "peer-scope-input actor-name-input";
|
|
2429
|
+
input.value = actorLabel(actor);
|
|
2430
|
+
input.setAttribute("aria-label", `Rename ${actorLabel(actor)}`);
|
|
2431
|
+
const renameBtn = el("button", "settings-button", "Rename");
|
|
2432
|
+
renameBtn.addEventListener("click", async () => {
|
|
2433
|
+
const nextName = input.value.trim();
|
|
2434
|
+
if (!nextName) return;
|
|
2435
|
+
renameBtn.disabled = true;
|
|
2436
|
+
input.disabled = true;
|
|
2437
|
+
renameBtn.textContent = "Saving…";
|
|
2438
|
+
try {
|
|
2439
|
+
await renameActor(actorId, nextName);
|
|
2440
|
+
await _loadSyncData();
|
|
2441
|
+
} catch {
|
|
2442
|
+
renameBtn.textContent = "Retry rename";
|
|
2443
|
+
} finally {
|
|
2444
|
+
renameBtn.disabled = false;
|
|
2445
|
+
input.disabled = false;
|
|
2446
|
+
if (renameBtn.textContent === "Saving…") renameBtn.textContent = "Rename";
|
|
2447
|
+
}
|
|
2448
|
+
});
|
|
2449
|
+
const mergeTargets = mergeTargetActors(actorId);
|
|
2450
|
+
const mergeControls = el("div", "actor-merge-controls");
|
|
2451
|
+
const mergeSelect = document.createElement("select");
|
|
2452
|
+
mergeSelect.className = "sync-actor-select actor-merge-select";
|
|
2453
|
+
mergeSelect.setAttribute("aria-label", `Merge ${actorLabel(actor)} into another actor`);
|
|
2454
|
+
const placeholder = document.createElement("option");
|
|
2455
|
+
placeholder.value = "";
|
|
2456
|
+
placeholder.textContent = "Merge into actor";
|
|
2457
|
+
placeholder.selected = true;
|
|
2458
|
+
mergeSelect.appendChild(placeholder);
|
|
2459
|
+
mergeTargets.forEach((target) => {
|
|
2460
|
+
const option = document.createElement("option");
|
|
2461
|
+
option.value = String(target.actor_id || "");
|
|
2462
|
+
option.textContent = target.is_local ? `${actorLabel(target)} (local)` : actorLabel(target);
|
|
2463
|
+
mergeSelect.appendChild(option);
|
|
2464
|
+
});
|
|
2465
|
+
const mergeBtn = el("button", "settings-button", "Merge into selected actor");
|
|
2466
|
+
mergeBtn.disabled = mergeTargets.length === 0;
|
|
2467
|
+
const mergeNote = el("div", "peer-meta actor-merge-note", mergeTargets.length ? actorMergeNote("", actorId) : "No merge targets yet. Create another actor or use the local actor.");
|
|
2468
|
+
mergeSelect.addEventListener("change", () => {
|
|
2469
|
+
mergeNote.textContent = actorMergeNote(mergeSelect.value, actorId);
|
|
2470
|
+
});
|
|
2471
|
+
mergeBtn.addEventListener("click", async () => {
|
|
2472
|
+
if (!mergeSelect.value) return;
|
|
2473
|
+
const target = mergeTargets.find((candidate) => String(candidate.actor_id || "") === mergeSelect.value);
|
|
2474
|
+
if (!window.confirm(`Merge ${actorLabel(actor)} into ${actorLabel(target)}? Assigned devices move now, but older memories keep their current stamped provenance for now.`)) return;
|
|
2475
|
+
mergeBtn.disabled = true;
|
|
2476
|
+
mergeSelect.disabled = true;
|
|
2477
|
+
input.disabled = true;
|
|
2478
|
+
renameBtn.disabled = true;
|
|
2479
|
+
mergeBtn.textContent = "Merging…";
|
|
2480
|
+
try {
|
|
2481
|
+
await mergeActor(mergeSelect.value, actorId);
|
|
2482
|
+
showGlobalNotice("Actor merged. Assigned devices moved to the selected actor.");
|
|
2483
|
+
await _loadSyncData();
|
|
2484
|
+
} catch (error) {
|
|
2485
|
+
showGlobalNotice(friendlyError(error, "Failed to merge actor."), "warning");
|
|
2486
|
+
mergeBtn.textContent = "Retry merge";
|
|
2487
|
+
} finally {
|
|
2488
|
+
mergeBtn.disabled = mergeTargets.length === 0;
|
|
2489
|
+
mergeSelect.disabled = false;
|
|
2490
|
+
input.disabled = false;
|
|
2491
|
+
renameBtn.disabled = false;
|
|
2492
|
+
if (mergeBtn.textContent === "Merging…") mergeBtn.textContent = "Merge into selected actor";
|
|
2493
|
+
}
|
|
2494
|
+
});
|
|
2495
|
+
mergeControls.append(mergeSelect, mergeBtn);
|
|
2496
|
+
actions.append(input, renameBtn, mergeControls, mergeNote);
|
|
2497
|
+
}
|
|
2498
|
+
row.append(details, actions);
|
|
2499
|
+
actorList.appendChild(row);
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
function renderSyncPeers() {
|
|
2503
|
+
const syncPeers = document.getElementById("syncPeers");
|
|
2504
|
+
if (!syncPeers) return;
|
|
2505
|
+
hideSkeleton("syncPeersSkeleton");
|
|
2506
|
+
syncPeers.textContent = "";
|
|
2507
|
+
const peers = state.lastSyncPeers;
|
|
2508
|
+
if (!Array.isArray(peers) || !peers.length) {
|
|
2509
|
+
syncPeers.appendChild(el("div", "sync-empty-state", "No devices paired yet. Use the pairing command in Diagnostics to connect another device."));
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
peers.forEach((peer) => {
|
|
2513
|
+
const card = el("div", "peer-card");
|
|
2514
|
+
const titleRow = el("div", "peer-title");
|
|
2515
|
+
const peerId = peer.peer_device_id ? String(peer.peer_device_id) : "";
|
|
2516
|
+
const displayName = peer.name || (peerId ? peerId.slice(0, 8) : "unknown");
|
|
2517
|
+
const name = el("strong", null, displayName);
|
|
2518
|
+
if (peerId) name.title = peerId;
|
|
2519
|
+
const peerStatus = peer.status || {};
|
|
2520
|
+
const online = peerStatus.sync_status === "ok" || peerStatus.ping_status === "ok";
|
|
2521
|
+
const badge = el("span", `badge ${online ? "badge-online" : "badge-offline"}`, online ? "Online" : "Offline");
|
|
2522
|
+
name.append(" ", badge);
|
|
2523
|
+
const actions = el("div", "peer-actions");
|
|
2524
|
+
const primaryAddress = pickPrimaryAddress(peer.addresses);
|
|
2525
|
+
const syncBtn = el("button", null, "Sync now");
|
|
2526
|
+
syncBtn.disabled = !primaryAddress;
|
|
2527
|
+
syncBtn.addEventListener("click", async () => {
|
|
2528
|
+
syncBtn.disabled = true;
|
|
2529
|
+
syncBtn.textContent = "Syncing…";
|
|
2530
|
+
try {
|
|
2531
|
+
await triggerSync(primaryAddress);
|
|
2532
|
+
} catch {}
|
|
2533
|
+
syncBtn.disabled = false;
|
|
2534
|
+
syncBtn.textContent = "Sync now";
|
|
2535
|
+
});
|
|
2536
|
+
actions.appendChild(syncBtn);
|
|
2537
|
+
const toggleScopeBtn = el("button", null, "Edit scope");
|
|
2538
|
+
actions.appendChild(toggleScopeBtn);
|
|
2539
|
+
const peerAddresses = Array.isArray(peer.addresses) ? Array.from(new Set(peer.addresses.filter(Boolean))) : [];
|
|
2540
|
+
const addressLabel = el("div", "peer-addresses", peerAddresses.length ? peerAddresses.map((a) => isSyncRedactionEnabled() ? redactAddress(a) : a).join(" · ") : "No addresses");
|
|
2541
|
+
const lastSyncAt = peerStatus.last_sync_at || peerStatus.last_sync_at_utc || "";
|
|
2542
|
+
const lastPingAt = peerStatus.last_ping_at || peerStatus.last_ping_at_utc || "";
|
|
2543
|
+
const meta = el("div", "peer-meta", [lastSyncAt ? `Sync: ${formatTimestamp(lastSyncAt)}` : "Sync: never", lastPingAt ? `Ping: ${formatTimestamp(lastPingAt)}` : "Ping: never"].join(" · "));
|
|
2544
|
+
const identityMeta = el("div", "peer-meta", peer.actor_display_name ? `Assigned to ${String(peer.actor_display_name)}${peer.claimed_local_actor ? " · local actor" : ""}` : "Unassigned actor");
|
|
2545
|
+
const scope = peer.project_scope || {};
|
|
2546
|
+
const includeList = Array.isArray(scope.include) ? scope.include : [];
|
|
2547
|
+
const excludeList = Array.isArray(scope.exclude) ? scope.exclude : [];
|
|
2548
|
+
const effectiveInclude = Array.isArray(scope.effective_include) ? scope.effective_include : [];
|
|
2549
|
+
const effectiveExclude = Array.isArray(scope.effective_exclude) ? scope.effective_exclude : [];
|
|
2550
|
+
const inheritsGlobal = Boolean(scope.inherits_global);
|
|
2551
|
+
const scopePanel = el("div", "peer-scope");
|
|
2552
|
+
const identityRow = el("div", "peer-scope-summary");
|
|
2553
|
+
identityRow.textContent = "Assigned actor";
|
|
2554
|
+
const actorRow = el("div", "peer-actor-row");
|
|
2555
|
+
const actorSelect = document.createElement("select");
|
|
2556
|
+
actorSelect.className = "sync-actor-select";
|
|
2557
|
+
actorSelect.setAttribute("aria-label", `Assigned actor for ${displayName}`);
|
|
2558
|
+
buildActorOptions(String(peer.actor_id || "")).forEach((option) => actorSelect.appendChild(option));
|
|
2559
|
+
const applyActorBtn = el("button", "settings-button", "Save actor");
|
|
2560
|
+
const actorHint = el("div", "peer-scope-effective", assignmentNote(String(peer.actor_id || "")));
|
|
2561
|
+
actorSelect.addEventListener("change", () => {
|
|
2562
|
+
actorHint.textContent = assignmentNote(actorSelect.value);
|
|
2563
|
+
});
|
|
2564
|
+
applyActorBtn.addEventListener("click", async () => {
|
|
2565
|
+
applyActorBtn.disabled = true;
|
|
2566
|
+
actorSelect.disabled = true;
|
|
2567
|
+
applyActorBtn.textContent = "Applying…";
|
|
2568
|
+
try {
|
|
2569
|
+
await assignPeerActor(peerId, actorSelect.value || null);
|
|
2570
|
+
showGlobalNotice(actorSelect.value ? "Device actor updated." : "Device actor cleared.");
|
|
2571
|
+
await _loadSyncData();
|
|
2572
|
+
} catch (error) {
|
|
2573
|
+
showGlobalNotice(friendlyError(error, "Failed to update device actor."), "warning");
|
|
2574
|
+
applyActorBtn.textContent = "Retry";
|
|
2575
|
+
} finally {
|
|
2576
|
+
actorSelect.disabled = false;
|
|
2577
|
+
applyActorBtn.disabled = false;
|
|
2578
|
+
if (applyActorBtn.textContent === "Applying…") applyActorBtn.textContent = "Save actor";
|
|
2579
|
+
}
|
|
2580
|
+
});
|
|
2581
|
+
actorRow.append(actorSelect, applyActorBtn);
|
|
2582
|
+
const scopeSummary = el("div", "peer-scope-summary", inheritsGlobal ? "Using global sync scope" : `Device override \u00b7 include: ${includeList.join(", ") || "all"} \u00b7 exclude: ${excludeList.join(", ") || "none"}`);
|
|
2583
|
+
const effectiveSummary = el("div", "peer-scope-effective", `Effective scope \u00b7 include: ${effectiveInclude.join(", ") || "all"} \u00b7 exclude: ${effectiveExclude.join(", ") || "none"}`);
|
|
2584
|
+
const includeEditor = createChipEditor(includeList, "Add included project", "All projects");
|
|
2585
|
+
const excludeEditor = createChipEditor(excludeList, "Add excluded project", "No exclusions");
|
|
2586
|
+
const scopeEditorOpen = openPeerScopeEditors.has(peerId);
|
|
2587
|
+
const editorWrap = el("div", `peer-scope-editor-wrap${scopeEditorOpen ? "" : " collapsed"}`);
|
|
2588
|
+
if (!scopeEditorOpen) editorWrap.inert = true;
|
|
2589
|
+
const inputRow = el("div", "peer-scope-row");
|
|
2590
|
+
inputRow.append(includeEditor.element, excludeEditor.element);
|
|
2591
|
+
const scopeActions = el("div", "peer-scope-actions");
|
|
2592
|
+
const saveScopeBtn = el("button", "settings-button", "Save scope");
|
|
2593
|
+
const inheritBtn = el("button", "settings-button", "Reset to global scope");
|
|
2594
|
+
saveScopeBtn.addEventListener("click", async () => {
|
|
2595
|
+
saveScopeBtn.disabled = true;
|
|
2596
|
+
saveScopeBtn.textContent = "Saving…";
|
|
2597
|
+
try {
|
|
2598
|
+
await updatePeerScope(peerId, includeEditor.values(), excludeEditor.values());
|
|
2599
|
+
showGlobalNotice("Device sync scope saved.");
|
|
2600
|
+
await _loadSyncData();
|
|
2601
|
+
} catch (error) {
|
|
2602
|
+
showGlobalNotice(friendlyError(error, "Failed to save device scope."), "warning");
|
|
2603
|
+
saveScopeBtn.textContent = "Retry save";
|
|
2604
|
+
} finally {
|
|
2605
|
+
saveScopeBtn.disabled = false;
|
|
2606
|
+
if (saveScopeBtn.textContent === "Saving…") saveScopeBtn.textContent = "Save scope";
|
|
2607
|
+
}
|
|
2608
|
+
});
|
|
2609
|
+
inheritBtn.addEventListener("click", async () => {
|
|
2610
|
+
inheritBtn.disabled = true;
|
|
2611
|
+
inheritBtn.textContent = "Resetting…";
|
|
2612
|
+
try {
|
|
2613
|
+
await updatePeerScope(peerId, null, null, true);
|
|
2614
|
+
showGlobalNotice("Device sync scope reset to global defaults.");
|
|
2615
|
+
await _loadSyncData();
|
|
2616
|
+
} catch (error) {
|
|
2617
|
+
showGlobalNotice(friendlyError(error, "Failed to reset device scope."), "warning");
|
|
2618
|
+
inheritBtn.textContent = "Retry reset";
|
|
2619
|
+
} finally {
|
|
2620
|
+
inheritBtn.disabled = false;
|
|
2621
|
+
if (inheritBtn.textContent === "Resetting…") inheritBtn.textContent = "Reset to global scope";
|
|
2622
|
+
}
|
|
2623
|
+
});
|
|
2624
|
+
scopeActions.append(saveScopeBtn, inheritBtn);
|
|
2625
|
+
editorWrap.append(inputRow, scopeActions);
|
|
2626
|
+
toggleScopeBtn.textContent = scopeEditorOpen ? "Hide scope editor" : "Edit scope";
|
|
2627
|
+
toggleScopeBtn.setAttribute("aria-expanded", String(scopeEditorOpen));
|
|
2628
|
+
toggleScopeBtn.addEventListener("click", () => {
|
|
2629
|
+
const isCollapsed = editorWrap.classList.contains("collapsed");
|
|
2630
|
+
editorWrap.classList.toggle("collapsed", !isCollapsed);
|
|
2631
|
+
editorWrap.inert = !isCollapsed;
|
|
2632
|
+
if (!isCollapsed) openPeerScopeEditors.delete(peerId);
|
|
2633
|
+
else openPeerScopeEditors.add(peerId);
|
|
2634
|
+
toggleScopeBtn.setAttribute("aria-expanded", String(isCollapsed));
|
|
2635
|
+
toggleScopeBtn.textContent = isCollapsed ? "Hide scope editor" : "Edit scope";
|
|
2636
|
+
});
|
|
2637
|
+
scopePanel.append(identityRow, identityMeta, actorRow, actorHint, scopeSummary, effectiveSummary, editorWrap);
|
|
2638
|
+
titleRow.append(name, actions);
|
|
2639
|
+
card.append(titleRow, addressLabel, meta, scopePanel);
|
|
2640
|
+
syncPeers.appendChild(card);
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
function renderLegacyDeviceClaims() {
|
|
2644
|
+
const panel = document.getElementById("syncLegacyClaims");
|
|
2645
|
+
const select = document.getElementById("syncLegacyDeviceSelect");
|
|
2646
|
+
const button = document.getElementById("syncLegacyClaimButton");
|
|
2647
|
+
const meta = document.getElementById("syncLegacyClaimsMeta");
|
|
2648
|
+
if (!panel || !select || !button || !meta) return;
|
|
2649
|
+
const devices = Array.isArray(state.lastSyncLegacyDevices) ? state.lastSyncLegacyDevices : [];
|
|
2650
|
+
select.textContent = "";
|
|
2651
|
+
meta.textContent = "";
|
|
2652
|
+
if (!devices.length) {
|
|
2653
|
+
panel.hidden = true;
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
panel.hidden = false;
|
|
2657
|
+
devices.forEach((device, index) => {
|
|
2658
|
+
const option = document.createElement("option");
|
|
2659
|
+
const deviceId = String(device.origin_device_id || "").trim();
|
|
2660
|
+
if (!deviceId) return;
|
|
2661
|
+
const count = Number(device.memory_count || 0);
|
|
2662
|
+
const lastSeen = String(device.last_seen_at || "").trim();
|
|
2663
|
+
option.value = deviceId;
|
|
2664
|
+
option.textContent = count > 0 ? `${deviceId} (${count} memories)` : deviceId;
|
|
2665
|
+
if (index === 0) option.selected = true;
|
|
2666
|
+
select.appendChild(option);
|
|
2667
|
+
if (!meta.textContent && lastSeen) meta.textContent = `Detected from older synced memories. Latest memory: ${formatTimestamp(lastSeen)}`;
|
|
2668
|
+
});
|
|
2669
|
+
if (!meta.textContent) meta.textContent = "Detected from older synced memories not yet attached to a current device.";
|
|
2670
|
+
}
|
|
2671
|
+
function initPeopleEvents(loadSyncData) {
|
|
2672
|
+
const syncActorCreateButton = document.getElementById("syncActorCreateButton");
|
|
2673
|
+
const syncActorCreateInput = document.getElementById("syncActorCreateInput");
|
|
2674
|
+
const syncLegacyClaimButton = document.getElementById("syncLegacyClaimButton");
|
|
2675
|
+
const syncLegacyDeviceSelect = document.getElementById("syncLegacyDeviceSelect");
|
|
2676
|
+
syncActorCreateButton?.addEventListener("click", async () => {
|
|
2677
|
+
if (!syncActorCreateButton || !syncActorCreateInput) return;
|
|
2678
|
+
const displayName = String(syncActorCreateInput.value || "").trim();
|
|
2679
|
+
if (!displayName) {
|
|
2680
|
+
markFieldError(syncActorCreateInput, "Enter a name for the actor.");
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
clearFieldError(syncActorCreateInput);
|
|
2684
|
+
syncActorCreateButton.disabled = true;
|
|
2685
|
+
syncActorCreateInput.disabled = true;
|
|
2686
|
+
syncActorCreateButton.textContent = "Creating…";
|
|
2687
|
+
try {
|
|
2688
|
+
await createActor(displayName);
|
|
2689
|
+
showGlobalNotice("Actor created.");
|
|
2690
|
+
syncActorCreateInput.value = "";
|
|
2691
|
+
await loadSyncData();
|
|
2692
|
+
} catch (error) {
|
|
2693
|
+
showGlobalNotice(friendlyError(error, "Failed to create actor."), "warning");
|
|
2694
|
+
syncActorCreateButton.textContent = "Retry";
|
|
2695
|
+
syncActorCreateButton.disabled = false;
|
|
2696
|
+
syncActorCreateInput.disabled = false;
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
syncActorCreateButton.textContent = "Create actor";
|
|
2700
|
+
syncActorCreateButton.disabled = false;
|
|
2701
|
+
syncActorCreateInput.disabled = false;
|
|
2702
|
+
});
|
|
2703
|
+
syncLegacyClaimButton?.addEventListener("click", async () => {
|
|
2704
|
+
const originDeviceId = String(syncLegacyDeviceSelect?.value || "").trim();
|
|
2705
|
+
if (!originDeviceId || !syncLegacyClaimButton) return;
|
|
2706
|
+
if (!window.confirm(`Attach old device history from ${originDeviceId} to your local actor? This updates legacy provenance for that device.`)) return;
|
|
2707
|
+
syncLegacyClaimButton.disabled = true;
|
|
2708
|
+
const originalText = syncLegacyClaimButton.textContent || "Attach device history";
|
|
2709
|
+
syncLegacyClaimButton.textContent = "Attaching…";
|
|
2710
|
+
try {
|
|
2711
|
+
await claimLegacyDeviceIdentity(originDeviceId);
|
|
2712
|
+
showGlobalNotice("Old device history attached to your local actor.");
|
|
2713
|
+
await loadSyncData();
|
|
2714
|
+
} catch (error) {
|
|
2715
|
+
showGlobalNotice(friendlyError(error, "Failed to attach old device history."), "warning");
|
|
2716
|
+
syncLegacyClaimButton.textContent = "Retry";
|
|
2717
|
+
syncLegacyClaimButton.disabled = false;
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
syncLegacyClaimButton.textContent = originalText;
|
|
2721
|
+
syncLegacyClaimButton.disabled = false;
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
//#endregion
|
|
2725
|
+
//#region src/tabs/sync/index.ts
|
|
2726
|
+
var lastSyncHash = "";
|
|
2727
|
+
async function loadSyncData() {
|
|
2728
|
+
try {
|
|
2729
|
+
const payload = await loadSyncStatus(true, state.currentProject || "");
|
|
2730
|
+
let actorsPayload = null;
|
|
2731
|
+
let actorLoadError = false;
|
|
2732
|
+
try {
|
|
2733
|
+
actorsPayload = await loadSyncActors();
|
|
2734
|
+
} catch {
|
|
2735
|
+
actorLoadError = true;
|
|
2736
|
+
}
|
|
2737
|
+
const hash = JSON.stringify([payload, actorsPayload]);
|
|
2738
|
+
if (hash === lastSyncHash) return;
|
|
2739
|
+
lastSyncHash = hash;
|
|
2740
|
+
const statusPayload = payload.status && typeof payload.status === "object" ? payload.status : null;
|
|
2741
|
+
if (statusPayload) state.lastSyncStatus = statusPayload;
|
|
2742
|
+
state.lastSyncActors = Array.isArray(actorsPayload?.items) ? actorsPayload.items : [];
|
|
2743
|
+
state.lastSyncPeers = payload.peers || [];
|
|
2744
|
+
state.lastSyncSharingReview = payload.sharing_review || [];
|
|
2745
|
+
state.lastSyncCoordinator = payload.coordinator || null;
|
|
2746
|
+
state.lastSyncJoinRequests = payload.join_requests || [];
|
|
2747
|
+
state.lastSyncAttempts = payload.attempts || [];
|
|
2748
|
+
state.lastSyncLegacyDevices = payload.legacy_devices || [];
|
|
2749
|
+
renderSyncStatus();
|
|
2750
|
+
renderTeamSync();
|
|
2751
|
+
renderSyncActors();
|
|
2752
|
+
renderSyncSharingReview();
|
|
2753
|
+
renderSyncPeers();
|
|
2754
|
+
renderLegacyDeviceClaims();
|
|
2755
|
+
renderSyncAttempts();
|
|
2756
|
+
renderHealthOverview();
|
|
2757
|
+
if (actorLoadError) {
|
|
2758
|
+
const actorMeta = document.getElementById("syncActorsMeta");
|
|
2759
|
+
if (actorMeta) actorMeta.textContent = "Actor controls are temporarily unavailable. Peer status and sync health still loaded.";
|
|
2760
|
+
}
|
|
2761
|
+
} catch {
|
|
2762
|
+
hideSkeleton("syncTeamSkeleton");
|
|
2763
|
+
hideSkeleton("syncActorsSkeleton");
|
|
2764
|
+
hideSkeleton("syncPeersSkeleton");
|
|
2765
|
+
hideSkeleton("syncDiagSkeleton");
|
|
2766
|
+
const syncMeta = document.getElementById("syncMeta");
|
|
2767
|
+
if (syncMeta) syncMeta.textContent = "Sync unavailable";
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
async function loadPairingData() {
|
|
2771
|
+
try {
|
|
2772
|
+
state.pairingPayloadRaw = await loadPairing() || null;
|
|
2773
|
+
renderPairing();
|
|
2774
|
+
} catch {
|
|
2775
|
+
renderPairing();
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
function initSyncTab(refreshCallback) {
|
|
2779
|
+
setLoadSyncData$1(loadSyncData);
|
|
2780
|
+
setLoadSyncData(loadSyncData);
|
|
2781
|
+
setRenderSyncPeers(renderSyncPeers);
|
|
2782
|
+
initTeamSyncEvents(refreshCallback, loadSyncData);
|
|
2783
|
+
initPeopleEvents(loadSyncData);
|
|
2784
|
+
initDiagnosticsEvents(refreshCallback);
|
|
2785
|
+
}
|
|
2786
|
+
//#endregion
|
|
2787
|
+
//#region src/tabs/settings.ts
|
|
2788
|
+
var settingsOpen = false;
|
|
2789
|
+
var previouslyFocused = null;
|
|
2790
|
+
var settingsActiveTab = "observer";
|
|
2791
|
+
var settingsBaseline = {};
|
|
2792
|
+
var settingsEnvOverrides = {};
|
|
2793
|
+
var settingsTouchedKeys = /* @__PURE__ */ new Set();
|
|
2794
|
+
var helpTooltipEl = null;
|
|
2795
|
+
var helpTooltipAnchor = null;
|
|
2796
|
+
var helpTooltipBound = false;
|
|
2797
|
+
var SETTINGS_ADVANCED_KEY = "codemem-settings-advanced";
|
|
2798
|
+
var settingsShowAdvanced = loadAdvancedPreference();
|
|
2799
|
+
var DEFAULT_OPENAI_MODEL = "gpt-5.1-codex-mini";
|
|
2800
|
+
var DEFAULT_ANTHROPIC_MODEL = "claude-4.5-haiku";
|
|
2801
|
+
var INPUT_TO_CONFIG_KEY = {
|
|
2802
|
+
claudeCommand: "claude_command",
|
|
2803
|
+
observerProvider: "observer_provider",
|
|
2804
|
+
observerModel: "observer_model",
|
|
2805
|
+
observerRuntime: "observer_runtime",
|
|
2806
|
+
observerAuthSource: "observer_auth_source",
|
|
2807
|
+
observerAuthFile: "observer_auth_file",
|
|
2808
|
+
observerAuthCommand: "observer_auth_command",
|
|
2809
|
+
observerAuthTimeoutMs: "observer_auth_timeout_ms",
|
|
2810
|
+
observerAuthCacheTtlS: "observer_auth_cache_ttl_s",
|
|
2811
|
+
observerHeaders: "observer_headers",
|
|
2812
|
+
observerMaxChars: "observer_max_chars",
|
|
2813
|
+
packObservationLimit: "pack_observation_limit",
|
|
2814
|
+
packSessionLimit: "pack_session_limit",
|
|
2815
|
+
rawEventsSweeperIntervalS: "raw_events_sweeper_interval_s",
|
|
2816
|
+
syncEnabled: "sync_enabled",
|
|
2817
|
+
syncHost: "sync_host",
|
|
2818
|
+
syncPort: "sync_port",
|
|
2819
|
+
syncInterval: "sync_interval_s",
|
|
2820
|
+
syncMdns: "sync_mdns",
|
|
2821
|
+
syncCoordinatorUrl: "sync_coordinator_url",
|
|
2822
|
+
syncCoordinatorGroup: "sync_coordinator_group",
|
|
2823
|
+
syncCoordinatorTimeout: "sync_coordinator_timeout_s",
|
|
2824
|
+
syncCoordinatorPresenceTtl: "sync_coordinator_presence_ttl_s"
|
|
2825
|
+
};
|
|
2826
|
+
function loadAdvancedPreference() {
|
|
2827
|
+
try {
|
|
2828
|
+
return globalThis.localStorage?.getItem(SETTINGS_ADVANCED_KEY) === "1";
|
|
2829
|
+
} catch {
|
|
2830
|
+
return false;
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
function persistAdvancedPreference(show) {
|
|
2834
|
+
try {
|
|
2835
|
+
globalThis.localStorage?.setItem(SETTINGS_ADVANCED_KEY, show ? "1" : "0");
|
|
2836
|
+
} catch {}
|
|
2837
|
+
}
|
|
2838
|
+
function hasOwn(obj, key) {
|
|
2839
|
+
return typeof obj === "object" && obj !== null && Object.prototype.hasOwnProperty.call(obj, key);
|
|
2840
|
+
}
|
|
2841
|
+
function effectiveOrConfigured(config, effective, key) {
|
|
2842
|
+
if (hasOwn(effective, key)) return effective[key];
|
|
2843
|
+
if (hasOwn(config, key)) return config[key];
|
|
2844
|
+
}
|
|
2845
|
+
function asInputString(value) {
|
|
2846
|
+
if (value === void 0 || value === null) return "";
|
|
2847
|
+
return String(value);
|
|
2848
|
+
}
|
|
2849
|
+
function toProviderList(value) {
|
|
2850
|
+
if (!Array.isArray(value)) return [];
|
|
2851
|
+
return value.filter((item) => typeof item === "string" && item.trim().length > 0);
|
|
2852
|
+
}
|
|
2853
|
+
function isEqualValue(left, right) {
|
|
2854
|
+
if (left === right) return true;
|
|
2855
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
2856
|
+
}
|
|
2857
|
+
function normalizeTextValue(value) {
|
|
2858
|
+
const trimmed = value.trim();
|
|
2859
|
+
return trimmed === "" ? "" : trimmed;
|
|
2860
|
+
}
|
|
2861
|
+
function inferObserverModel(runtime, provider, configuredModel) {
|
|
2862
|
+
if (configuredModel) return {
|
|
2863
|
+
model: configuredModel,
|
|
2864
|
+
source: "Configured"
|
|
2865
|
+
};
|
|
2866
|
+
if (runtime === "claude_sidecar") return {
|
|
2867
|
+
model: DEFAULT_ANTHROPIC_MODEL,
|
|
2868
|
+
source: "Recommended (local Claude session)"
|
|
2869
|
+
};
|
|
2870
|
+
if (provider === "anthropic") return {
|
|
2871
|
+
model: DEFAULT_ANTHROPIC_MODEL,
|
|
2872
|
+
source: "Recommended (Anthropic provider)"
|
|
2873
|
+
};
|
|
2874
|
+
if (provider && provider !== "openai") return {
|
|
2875
|
+
model: "provider default",
|
|
2876
|
+
source: "Recommended (provider default)"
|
|
2877
|
+
};
|
|
2878
|
+
return {
|
|
2879
|
+
model: DEFAULT_OPENAI_MODEL,
|
|
2880
|
+
source: "Recommended (direct API)"
|
|
2881
|
+
};
|
|
2882
|
+
}
|
|
2883
|
+
function configuredValueForKey(config, key) {
|
|
2884
|
+
switch (key) {
|
|
2885
|
+
case "claude_command": {
|
|
2886
|
+
const value = config?.claude_command;
|
|
2887
|
+
if (!Array.isArray(value)) return [];
|
|
2888
|
+
const normalized = [];
|
|
2889
|
+
value.forEach((item) => {
|
|
2890
|
+
if (typeof item !== "string") return;
|
|
2891
|
+
const token = item.trim();
|
|
2892
|
+
if (token) normalized.push(token);
|
|
2893
|
+
});
|
|
2894
|
+
return normalized;
|
|
2895
|
+
}
|
|
2896
|
+
case "observer_provider":
|
|
2897
|
+
case "observer_model":
|
|
2898
|
+
case "observer_auth_file":
|
|
2899
|
+
case "sync_host":
|
|
2900
|
+
case "sync_coordinator_url":
|
|
2901
|
+
case "sync_coordinator_group": return normalizeTextValue(asInputString(config?.[key]));
|
|
2902
|
+
case "observer_runtime": return normalizeTextValue(asInputString(config?.observer_runtime));
|
|
2903
|
+
case "observer_auth_source": return normalizeTextValue(asInputString(config?.observer_auth_source));
|
|
2904
|
+
case "observer_auth_command": {
|
|
2905
|
+
const value = config?.observer_auth_command;
|
|
2906
|
+
if (!Array.isArray(value)) return [];
|
|
2907
|
+
return value.filter((item) => typeof item === "string");
|
|
2908
|
+
}
|
|
2909
|
+
case "observer_headers": {
|
|
2910
|
+
const value = config?.observer_headers;
|
|
2911
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
2912
|
+
const headers = {};
|
|
2913
|
+
Object.entries(value).forEach(([header, headerValue]) => {
|
|
2914
|
+
if (typeof header === "string" && header.trim() && typeof headerValue === "string") headers[header.trim()] = headerValue;
|
|
2915
|
+
});
|
|
2916
|
+
return headers;
|
|
2917
|
+
}
|
|
2918
|
+
case "observer_auth_timeout_ms":
|
|
2919
|
+
case "observer_max_chars":
|
|
2920
|
+
case "pack_observation_limit":
|
|
2921
|
+
case "pack_session_limit":
|
|
2922
|
+
case "raw_events_sweeper_interval_s":
|
|
2923
|
+
case "sync_port":
|
|
2924
|
+
case "sync_interval_s": {
|
|
2925
|
+
if (!hasOwn(config, key)) return "";
|
|
2926
|
+
const parsed = Number(config[key]);
|
|
2927
|
+
return Number.isFinite(parsed) && parsed !== 0 ? parsed : "";
|
|
2928
|
+
}
|
|
2929
|
+
case "sync_coordinator_timeout_s":
|
|
2930
|
+
case "sync_coordinator_presence_ttl_s": {
|
|
2931
|
+
if (!hasOwn(config, key)) return "";
|
|
2932
|
+
const parsed = Number(config[key]);
|
|
2933
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : "";
|
|
2934
|
+
}
|
|
2935
|
+
case "observer_auth_cache_ttl_s": {
|
|
2936
|
+
if (!hasOwn(config, key)) return "";
|
|
2937
|
+
const parsed = Number(config[key]);
|
|
2938
|
+
return Number.isFinite(parsed) ? parsed : "";
|
|
2939
|
+
}
|
|
2940
|
+
case "sync_enabled":
|
|
2941
|
+
case "sync_mdns": return Boolean(config?.[key]);
|
|
2942
|
+
default: return hasOwn(config, key) ? config[key] : "";
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
function mergeOverrideBaseline(baseline, config, envOverrides) {
|
|
2946
|
+
const next = { ...baseline };
|
|
2947
|
+
Object.keys(envOverrides).forEach((key) => {
|
|
2948
|
+
if (hasOwn(next, key)) next[key] = configuredValueForKey(config, key);
|
|
2949
|
+
});
|
|
2950
|
+
return next;
|
|
2951
|
+
}
|
|
2952
|
+
function renderObserverModelHint() {
|
|
2953
|
+
const hint = $("observerModelHint");
|
|
2954
|
+
if (!hint) return;
|
|
2955
|
+
const inferred = inferObserverModel(($select("observerRuntime")?.value || "api_http").trim(), ($select("observerProvider")?.value || "").trim(), normalizeTextValue($input("observerModel")?.value || ""));
|
|
2956
|
+
hint.textContent = `${[
|
|
2957
|
+
"observer_model",
|
|
2958
|
+
"observer_provider",
|
|
2959
|
+
"observer_runtime"
|
|
2960
|
+
].some((key) => hasOwn(settingsEnvOverrides, key)) ? "Env override" : inferred.source}: ${inferred.model}`;
|
|
2961
|
+
}
|
|
2962
|
+
function setAdvancedVisibility(show) {
|
|
2963
|
+
settingsShowAdvanced = show;
|
|
2964
|
+
const toggle = $input("settingsAdvancedToggle");
|
|
2965
|
+
if (toggle) toggle.checked = show;
|
|
2966
|
+
document.querySelectorAll(".settings-advanced").forEach((node) => {
|
|
2967
|
+
const el = node;
|
|
2968
|
+
el.hidden = !show;
|
|
2969
|
+
});
|
|
2970
|
+
}
|
|
2971
|
+
function ensureHelpTooltipElement() {
|
|
2972
|
+
if (helpTooltipEl) return helpTooltipEl;
|
|
2973
|
+
const el = document.createElement("div");
|
|
2974
|
+
el.className = "help-tooltip";
|
|
2975
|
+
el.hidden = true;
|
|
2976
|
+
document.body.appendChild(el);
|
|
2977
|
+
helpTooltipEl = el;
|
|
2978
|
+
return el;
|
|
2979
|
+
}
|
|
2980
|
+
function positionHelpTooltip(anchor) {
|
|
2981
|
+
const el = ensureHelpTooltipElement();
|
|
2982
|
+
const rect = anchor.getBoundingClientRect();
|
|
2983
|
+
const margin = 8;
|
|
2984
|
+
const gap = 8;
|
|
2985
|
+
const width = el.offsetWidth;
|
|
2986
|
+
const height = el.offsetHeight;
|
|
2987
|
+
let left = rect.left + rect.width / 2 - width / 2;
|
|
2988
|
+
left = Math.max(margin, Math.min(left, globalThis.innerWidth - width - margin));
|
|
2989
|
+
let top = rect.bottom + gap;
|
|
2990
|
+
if (top + height > globalThis.innerHeight - margin) top = rect.top - height - gap;
|
|
2991
|
+
top = Math.max(margin, top);
|
|
2992
|
+
el.style.left = `${Math.round(left)}px`;
|
|
2993
|
+
el.style.top = `${Math.round(top)}px`;
|
|
2994
|
+
}
|
|
2995
|
+
function showHelpTooltip(anchor) {
|
|
2996
|
+
const content = anchor.dataset.tooltip?.trim();
|
|
2997
|
+
if (!content) return;
|
|
2998
|
+
const el = ensureHelpTooltipElement();
|
|
2999
|
+
helpTooltipAnchor = anchor;
|
|
3000
|
+
el.textContent = content;
|
|
3001
|
+
el.hidden = false;
|
|
3002
|
+
requestAnimationFrame(() => {
|
|
3003
|
+
positionHelpTooltip(anchor);
|
|
3004
|
+
el.classList.add("visible");
|
|
3005
|
+
});
|
|
3006
|
+
}
|
|
3007
|
+
function hideHelpTooltip() {
|
|
3008
|
+
if (!helpTooltipEl) return;
|
|
3009
|
+
helpTooltipEl.classList.remove("visible");
|
|
3010
|
+
helpTooltipEl.hidden = true;
|
|
3011
|
+
helpTooltipAnchor = null;
|
|
3012
|
+
}
|
|
3013
|
+
function bindHelpTooltips() {
|
|
3014
|
+
if (helpTooltipBound) return;
|
|
3015
|
+
helpTooltipBound = true;
|
|
3016
|
+
document.querySelectorAll(".help-icon[data-tooltip]").forEach((node) => {
|
|
3017
|
+
const button = node;
|
|
3018
|
+
button.addEventListener("mouseenter", () => showHelpTooltip(button));
|
|
3019
|
+
button.addEventListener("mouseleave", () => hideHelpTooltip());
|
|
3020
|
+
button.addEventListener("focus", () => showHelpTooltip(button));
|
|
3021
|
+
button.addEventListener("blur", () => hideHelpTooltip());
|
|
3022
|
+
button.addEventListener("click", (event) => {
|
|
3023
|
+
event.preventDefault();
|
|
3024
|
+
if (helpTooltipAnchor === button && helpTooltipEl && !helpTooltipEl.hidden) {
|
|
3025
|
+
hideHelpTooltip();
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
showHelpTooltip(button);
|
|
3029
|
+
});
|
|
3030
|
+
});
|
|
3031
|
+
globalThis.addEventListener("resize", () => {
|
|
3032
|
+
if (helpTooltipAnchor) positionHelpTooltip(helpTooltipAnchor);
|
|
3033
|
+
});
|
|
3034
|
+
document.addEventListener("scroll", () => {
|
|
3035
|
+
if (helpTooltipAnchor) positionHelpTooltip(helpTooltipAnchor);
|
|
3036
|
+
}, true);
|
|
3037
|
+
}
|
|
3038
|
+
function markFieldTouched(inputId) {
|
|
3039
|
+
const key = INPUT_TO_CONFIG_KEY[inputId];
|
|
3040
|
+
if (!key) return;
|
|
3041
|
+
settingsTouchedKeys.add(key);
|
|
3042
|
+
}
|
|
3043
|
+
function setProviderOptions(selectEl, providers, currentValue) {
|
|
3044
|
+
if (!selectEl) return;
|
|
3045
|
+
const values = new Set(providers);
|
|
3046
|
+
if (currentValue) values.add(currentValue);
|
|
3047
|
+
selectEl.innerHTML = "";
|
|
3048
|
+
const autoOption = document.createElement("option");
|
|
3049
|
+
autoOption.value = "";
|
|
3050
|
+
autoOption.textContent = "auto (default)";
|
|
3051
|
+
selectEl.append(autoOption);
|
|
3052
|
+
Array.from(values).sort((a, b) => a.localeCompare(b)).forEach((provider) => {
|
|
3053
|
+
const option = document.createElement("option");
|
|
3054
|
+
option.value = provider;
|
|
3055
|
+
option.textContent = provider;
|
|
3056
|
+
selectEl.append(option);
|
|
3057
|
+
});
|
|
3058
|
+
selectEl.value = currentValue;
|
|
3059
|
+
}
|
|
3060
|
+
function getFocusableNodes(container) {
|
|
3061
|
+
if (!container) return [];
|
|
3062
|
+
const selector = [
|
|
3063
|
+
"button:not([disabled])",
|
|
3064
|
+
"input:not([disabled])",
|
|
3065
|
+
"select:not([disabled])",
|
|
3066
|
+
"textarea:not([disabled])",
|
|
3067
|
+
"[href]",
|
|
3068
|
+
"[tabindex]:not([tabindex=\"-1\"])"
|
|
3069
|
+
].join(",");
|
|
3070
|
+
return Array.from(container.querySelectorAll(selector)).filter((node) => {
|
|
3071
|
+
const el = node;
|
|
3072
|
+
return !el.hidden && el.offsetParent !== null;
|
|
3073
|
+
});
|
|
3074
|
+
}
|
|
3075
|
+
function trapModalFocus(event) {
|
|
3076
|
+
if (!settingsOpen || event.key !== "Tab") return;
|
|
3077
|
+
const modal = $("settingsModal");
|
|
3078
|
+
const focusable = getFocusableNodes(modal);
|
|
3079
|
+
if (!focusable.length) return;
|
|
3080
|
+
const first = focusable[0];
|
|
3081
|
+
const last = focusable[focusable.length - 1];
|
|
3082
|
+
const active = document.activeElement;
|
|
3083
|
+
if (event.shiftKey) {
|
|
3084
|
+
if (!active || active === first || !modal?.contains(active)) {
|
|
3085
|
+
event.preventDefault();
|
|
3086
|
+
last.focus();
|
|
3087
|
+
}
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
if (!active || active === last || !modal?.contains(active)) {
|
|
3091
|
+
event.preventDefault();
|
|
3092
|
+
first.focus();
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
function isSettingsOpen() {
|
|
3096
|
+
return settingsOpen;
|
|
3097
|
+
}
|
|
3098
|
+
function formatSettingsKey(key) {
|
|
3099
|
+
return String(key || "").replace(/_/g, " ");
|
|
3100
|
+
}
|
|
3101
|
+
function joinPhrases(values) {
|
|
3102
|
+
const items = values.filter((value) => typeof value === "string" && value.trim());
|
|
3103
|
+
if (items.length === 0) return "";
|
|
3104
|
+
if (items.length === 1) return items[0];
|
|
3105
|
+
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
|
3106
|
+
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
|
|
3107
|
+
}
|
|
3108
|
+
function buildSettingsNotice(payload) {
|
|
3109
|
+
const effects = payload?.effects && typeof payload.effects === "object" ? payload.effects : {};
|
|
3110
|
+
const hotReloaded = Array.isArray(effects.hot_reloaded_keys) ? effects.hot_reloaded_keys.map(formatSettingsKey) : [];
|
|
3111
|
+
const liveApplied = Array.isArray(effects.live_applied_keys) ? effects.live_applied_keys.map(formatSettingsKey) : [];
|
|
3112
|
+
const restartRequired = Array.isArray(effects.restart_required_keys) ? effects.restart_required_keys.map(formatSettingsKey) : [];
|
|
3113
|
+
const warnings = Array.isArray(effects.warnings) ? effects.warnings.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
|
|
3114
|
+
const manualActions = Array.isArray(effects.manual_actions) ? effects.manual_actions : [];
|
|
3115
|
+
const sync = effects.sync && typeof effects.sync === "object" ? effects.sync : {};
|
|
3116
|
+
const lines = [];
|
|
3117
|
+
if (hotReloaded.length) lines.push(`Applied now: ${joinPhrases(hotReloaded)}.`);
|
|
3118
|
+
if (liveApplied.length) lines.push(`Live settings updated: ${joinPhrases(liveApplied)}.`);
|
|
3119
|
+
if (sync.attempted && typeof sync.message === "string" && sync.message) lines.push(`Sync: ${sync.message}.`);
|
|
3120
|
+
else if (Array.isArray(sync.affected_keys) && sync.affected_keys.length && typeof sync.reason === "string" && sync.reason) lines.push(`Sync: ${sync.reason}.`);
|
|
3121
|
+
if (restartRequired.length) lines.push(`Manual restart required: ${joinPhrases(restartRequired)}.`);
|
|
3122
|
+
warnings.forEach((warning) => {
|
|
3123
|
+
lines.push(warning);
|
|
3124
|
+
});
|
|
3125
|
+
manualActions.forEach((action) => {
|
|
3126
|
+
if (action && typeof action.command === "string" && action.command.trim()) lines.push(`If needed: ${action.command}.`);
|
|
3127
|
+
});
|
|
3128
|
+
if (!lines.length) lines.push("Saved.");
|
|
3129
|
+
const hasWarning = restartRequired.length > 0 || warnings.length > 0 || sync.ok === false;
|
|
3130
|
+
return {
|
|
3131
|
+
message: lines.join(" "),
|
|
3132
|
+
type: hasWarning ? "warning" : "success"
|
|
3133
|
+
};
|
|
3134
|
+
}
|
|
3135
|
+
function renderConfigModal(payload) {
|
|
3136
|
+
if (!payload || typeof payload !== "object") return;
|
|
3137
|
+
const defaults = payload.defaults || {};
|
|
3138
|
+
const config = payload.config || {};
|
|
3139
|
+
const effective = payload.effective || {};
|
|
3140
|
+
const envOverrides = payload.env_overrides && typeof payload.env_overrides === "object" ? payload.env_overrides : {};
|
|
3141
|
+
settingsEnvOverrides = envOverrides;
|
|
3142
|
+
const providers = toProviderList(payload.providers);
|
|
3143
|
+
state.configDefaults = defaults;
|
|
3144
|
+
state.configPath = payload.path || "";
|
|
3145
|
+
const observerProvider = $select("observerProvider");
|
|
3146
|
+
const claudeCommand = document.getElementById("claudeCommand");
|
|
3147
|
+
const observerModel = $input("observerModel");
|
|
3148
|
+
const observerRuntime = $select("observerRuntime");
|
|
3149
|
+
const observerAuthSource = $select("observerAuthSource");
|
|
3150
|
+
const observerAuthFile = $input("observerAuthFile");
|
|
3151
|
+
const observerAuthCommand = document.getElementById("observerAuthCommand");
|
|
3152
|
+
const observerAuthTimeoutMs = $input("observerAuthTimeoutMs");
|
|
3153
|
+
const observerAuthCacheTtlS = $input("observerAuthCacheTtlS");
|
|
3154
|
+
const observerHeaders = document.getElementById("observerHeaders");
|
|
3155
|
+
const observerMaxChars = $input("observerMaxChars");
|
|
3156
|
+
const packObservationLimit = $input("packObservationLimit");
|
|
3157
|
+
const packSessionLimit = $input("packSessionLimit");
|
|
3158
|
+
const rawEventsSweeperIntervalS = $input("rawEventsSweeperIntervalS");
|
|
3159
|
+
const syncEnabled = $input("syncEnabled");
|
|
3160
|
+
const syncHost = $input("syncHost");
|
|
3161
|
+
const syncPort = $input("syncPort");
|
|
3162
|
+
const syncInterval = $input("syncInterval");
|
|
3163
|
+
const syncMdns = $input("syncMdns");
|
|
3164
|
+
const syncCoordinatorUrl = $input("syncCoordinatorUrl");
|
|
3165
|
+
const syncCoordinatorGroup = $input("syncCoordinatorGroup");
|
|
3166
|
+
const syncCoordinatorTimeout = $input("syncCoordinatorTimeout");
|
|
3167
|
+
const syncCoordinatorPresenceTtl = $input("syncCoordinatorPresenceTtl");
|
|
3168
|
+
const settingsPath = $("settingsPath");
|
|
3169
|
+
const observerModelHint = $("observerModelHint");
|
|
3170
|
+
const observerMaxCharsHint = $("observerMaxCharsHint");
|
|
3171
|
+
const settingsEffective = $("settingsEffective");
|
|
3172
|
+
setProviderOptions(observerProvider, providers, asInputString(effectiveOrConfigured(config, effective, "observer_provider")));
|
|
3173
|
+
if (claudeCommand) {
|
|
3174
|
+
const value = effectiveOrConfigured(config, effective, "claude_command");
|
|
3175
|
+
const argv = Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
3176
|
+
claudeCommand.value = argv.length ? JSON.stringify(argv, null, 2) : "";
|
|
3177
|
+
}
|
|
3178
|
+
const observerModelValue = asInputString(effectiveOrConfigured(config, effective, "observer_model"));
|
|
3179
|
+
if (observerModel) observerModel.value = observerModelValue;
|
|
3180
|
+
if (observerRuntime) observerRuntime.value = asInputString(effectiveOrConfigured(config, effective, "observer_runtime")) || "api_http";
|
|
3181
|
+
if (observerAuthSource) observerAuthSource.value = asInputString(effectiveOrConfigured(config, effective, "observer_auth_source")) || "auto";
|
|
3182
|
+
if (observerAuthFile) observerAuthFile.value = asInputString(effectiveOrConfigured(config, effective, "observer_auth_file"));
|
|
3183
|
+
if (observerAuthCommand) {
|
|
3184
|
+
const argv = effectiveOrConfigured(config, effective, "observer_auth_command");
|
|
3185
|
+
const commandStrings = (Array.isArray(argv) ? argv : []).filter((item) => typeof item === "string");
|
|
3186
|
+
observerAuthCommand.value = commandStrings.length ? JSON.stringify(commandStrings, null, 2) : "";
|
|
3187
|
+
}
|
|
3188
|
+
if (observerAuthTimeoutMs) observerAuthTimeoutMs.value = asInputString(effectiveOrConfigured(config, effective, "observer_auth_timeout_ms"));
|
|
3189
|
+
if (observerAuthCacheTtlS) observerAuthCacheTtlS.value = asInputString(effectiveOrConfigured(config, effective, "observer_auth_cache_ttl_s"));
|
|
3190
|
+
if (observerHeaders) {
|
|
3191
|
+
const headerValue = effectiveOrConfigured(config, effective, "observer_headers");
|
|
3192
|
+
const headers = headerValue && typeof headerValue === "object" ? headerValue : {};
|
|
3193
|
+
const normalized = {};
|
|
3194
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
3195
|
+
if (typeof key === "string" && key.trim() && typeof value === "string") normalized[key] = value;
|
|
3196
|
+
});
|
|
3197
|
+
observerHeaders.value = Object.keys(normalized).length ? JSON.stringify(normalized, null, 2) : "";
|
|
3198
|
+
}
|
|
3199
|
+
if (observerMaxChars) observerMaxChars.value = asInputString(effectiveOrConfigured(config, effective, "observer_max_chars"));
|
|
3200
|
+
if (packObservationLimit) packObservationLimit.value = asInputString(effectiveOrConfigured(config, effective, "pack_observation_limit"));
|
|
3201
|
+
if (packSessionLimit) packSessionLimit.value = asInputString(effectiveOrConfigured(config, effective, "pack_session_limit"));
|
|
3202
|
+
if (rawEventsSweeperIntervalS) rawEventsSweeperIntervalS.value = asInputString(effectiveOrConfigured(config, effective, "raw_events_sweeper_interval_s"));
|
|
3203
|
+
if (syncEnabled) syncEnabled.checked = Boolean(effectiveOrConfigured(config, effective, "sync_enabled"));
|
|
3204
|
+
if (syncHost) syncHost.value = asInputString(effectiveOrConfigured(config, effective, "sync_host"));
|
|
3205
|
+
if (syncPort) syncPort.value = asInputString(effectiveOrConfigured(config, effective, "sync_port"));
|
|
3206
|
+
if (syncInterval) syncInterval.value = asInputString(effectiveOrConfigured(config, effective, "sync_interval_s"));
|
|
3207
|
+
if (syncMdns) syncMdns.checked = Boolean(effectiveOrConfigured(config, effective, "sync_mdns"));
|
|
3208
|
+
if (syncCoordinatorUrl) syncCoordinatorUrl.value = asInputString(effectiveOrConfigured(config, effective, "sync_coordinator_url"));
|
|
3209
|
+
if (syncCoordinatorGroup) syncCoordinatorGroup.value = asInputString(effectiveOrConfigured(config, effective, "sync_coordinator_group"));
|
|
3210
|
+
if (syncCoordinatorTimeout) syncCoordinatorTimeout.value = asInputString(effectiveOrConfigured(config, effective, "sync_coordinator_timeout_s"));
|
|
3211
|
+
if (syncCoordinatorPresenceTtl) syncCoordinatorPresenceTtl.value = asInputString(effectiveOrConfigured(config, effective, "sync_coordinator_presence_ttl_s"));
|
|
3212
|
+
if (settingsPath) settingsPath.textContent = state.configPath ? `Config path: ${state.configPath}` : "Config path: n/a";
|
|
3213
|
+
if (observerModelHint) renderObserverModelHint();
|
|
3214
|
+
if (observerMaxCharsHint) {
|
|
3215
|
+
const def = defaults?.observer_max_chars || "";
|
|
3216
|
+
observerMaxCharsHint.textContent = def ? `Default: ${def}` : "";
|
|
3217
|
+
}
|
|
3218
|
+
if (settingsEffective) settingsEffective.textContent = Object.keys(envOverrides).length > 0 ? "Some fields are managed by environment settings." : "";
|
|
3219
|
+
const overrides = $("settingsOverrides");
|
|
3220
|
+
if (overrides) overrides.hidden = Object.keys(envOverrides).length === 0;
|
|
3221
|
+
updateAuthSourceVisibility();
|
|
3222
|
+
setAdvancedVisibility(settingsShowAdvanced);
|
|
3223
|
+
setSettingsTab(settingsActiveTab);
|
|
3224
|
+
settingsTouchedKeys = /* @__PURE__ */ new Set();
|
|
3225
|
+
try {
|
|
3226
|
+
settingsBaseline = mergeOverrideBaseline(collectSettingsPayload(), config, envOverrides);
|
|
3227
|
+
} catch {
|
|
3228
|
+
settingsBaseline = {};
|
|
3229
|
+
}
|
|
3230
|
+
setDirty(false);
|
|
3231
|
+
const settingsStatus = $("settingsStatus");
|
|
3232
|
+
if (settingsStatus) settingsStatus.textContent = "Ready";
|
|
3233
|
+
}
|
|
3234
|
+
function parseCommandArgv(raw, options) {
|
|
3235
|
+
const text = raw.trim();
|
|
3236
|
+
if (!text) return [];
|
|
3237
|
+
const parsed = JSON.parse(text);
|
|
3238
|
+
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) throw new Error(`${options.label} must be a JSON string array`);
|
|
3239
|
+
if (!options.normalize && !options.requireNonEmpty) return parsed;
|
|
3240
|
+
const values = options.normalize ? parsed.map((item) => item.trim()) : parsed;
|
|
3241
|
+
if (options.requireNonEmpty && values.some((item) => item.trim() === "")) throw new Error(`${options.label} cannot contain empty command tokens`);
|
|
3242
|
+
return values;
|
|
3243
|
+
}
|
|
3244
|
+
function parseObserverHeaders(raw) {
|
|
3245
|
+
const text = raw.trim();
|
|
3246
|
+
if (!text) return {};
|
|
3247
|
+
const parsed = JSON.parse(text);
|
|
3248
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("observer headers must be a JSON object");
|
|
3249
|
+
const headers = {};
|
|
3250
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
3251
|
+
if (typeof key !== "string" || !key.trim() || typeof value !== "string") throw new Error("observer headers must map string keys to string values");
|
|
3252
|
+
headers[key.trim()] = value;
|
|
3253
|
+
}
|
|
3254
|
+
return headers;
|
|
3255
|
+
}
|
|
3256
|
+
function collectSettingsPayload(options = {}) {
|
|
3257
|
+
const allowUntouchedParseErrors = options.allowUntouchedParseErrors === true;
|
|
3258
|
+
const claudeCommandInput = document.getElementById("claudeCommand")?.value || "";
|
|
3259
|
+
const authCommandInput = document.getElementById("observerAuthCommand")?.value || "";
|
|
3260
|
+
const observerHeadersInput = document.getElementById("observerHeaders")?.value || "";
|
|
3261
|
+
const authCacheTtlInput = ($input("observerAuthCacheTtlS")?.value || "").trim();
|
|
3262
|
+
const sweeperIntervalInput = ($input("rawEventsSweeperIntervalS")?.value || "").trim();
|
|
3263
|
+
let claudeCommand = [];
|
|
3264
|
+
try {
|
|
3265
|
+
claudeCommand = parseCommandArgv(claudeCommandInput, {
|
|
3266
|
+
label: "claude command",
|
|
3267
|
+
normalize: true,
|
|
3268
|
+
requireNonEmpty: true
|
|
3269
|
+
});
|
|
3270
|
+
} catch (error) {
|
|
3271
|
+
if (!allowUntouchedParseErrors || settingsTouchedKeys.has("claude_command")) throw error;
|
|
3272
|
+
const baseline = settingsBaseline.claude_command;
|
|
3273
|
+
claudeCommand = Array.isArray(baseline) ? baseline.filter((item) => typeof item === "string").map((item) => item.trim()).filter((item) => item.length > 0) : [];
|
|
3274
|
+
}
|
|
3275
|
+
let authCommand = [];
|
|
3276
|
+
try {
|
|
3277
|
+
authCommand = parseCommandArgv(authCommandInput, { label: "observer auth command" });
|
|
3278
|
+
} catch (error) {
|
|
3279
|
+
if (!allowUntouchedParseErrors || settingsTouchedKeys.has("observer_auth_command")) throw error;
|
|
3280
|
+
const baseline = settingsBaseline.observer_auth_command;
|
|
3281
|
+
authCommand = Array.isArray(baseline) ? baseline.filter((item) => typeof item === "string") : [];
|
|
3282
|
+
}
|
|
3283
|
+
let headers = {};
|
|
3284
|
+
try {
|
|
3285
|
+
headers = parseObserverHeaders(observerHeadersInput);
|
|
3286
|
+
} catch (error) {
|
|
3287
|
+
if (!allowUntouchedParseErrors || settingsTouchedKeys.has("observer_headers")) throw error;
|
|
3288
|
+
const baseline = settingsBaseline.observer_headers;
|
|
3289
|
+
if (baseline && typeof baseline === "object" && !Array.isArray(baseline)) Object.entries(baseline).forEach(([key, value]) => {
|
|
3290
|
+
if (typeof key === "string" && key.trim() && typeof value === "string") headers[key] = value;
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
const authCacheTtl = authCacheTtlInput === "" ? "" : Number(authCacheTtlInput);
|
|
3294
|
+
const sweeperIntervalNum = Number(sweeperIntervalInput);
|
|
3295
|
+
const sweeperInterval = sweeperIntervalInput === "" ? "" : sweeperIntervalNum;
|
|
3296
|
+
if (authCacheTtlInput !== "" && !Number.isFinite(authCacheTtl)) throw new Error("observer auth cache ttl must be a number");
|
|
3297
|
+
if (sweeperIntervalInput !== "" && (!Number.isFinite(sweeperIntervalNum) || sweeperIntervalNum <= 0)) throw new Error("raw-event sweeper interval must be a positive number");
|
|
3298
|
+
return {
|
|
3299
|
+
claude_command: claudeCommand,
|
|
3300
|
+
observer_provider: normalizeTextValue($select("observerProvider")?.value || ""),
|
|
3301
|
+
observer_model: normalizeTextValue($input("observerModel")?.value || ""),
|
|
3302
|
+
observer_runtime: normalizeTextValue($select("observerRuntime")?.value || "api_http") || "api_http",
|
|
3303
|
+
observer_auth_source: normalizeTextValue($select("observerAuthSource")?.value || "auto") || "auto",
|
|
3304
|
+
observer_auth_file: normalizeTextValue($input("observerAuthFile")?.value || ""),
|
|
3305
|
+
observer_auth_command: authCommand,
|
|
3306
|
+
observer_auth_timeout_ms: Number($input("observerAuthTimeoutMs")?.value || 0) || "",
|
|
3307
|
+
observer_auth_cache_ttl_s: authCacheTtl,
|
|
3308
|
+
observer_headers: headers,
|
|
3309
|
+
observer_max_chars: Number($input("observerMaxChars")?.value || 0) || "",
|
|
3310
|
+
pack_observation_limit: Number($input("packObservationLimit")?.value || 0) || "",
|
|
3311
|
+
pack_session_limit: Number($input("packSessionLimit")?.value || 0) || "",
|
|
3312
|
+
raw_events_sweeper_interval_s: sweeperInterval,
|
|
3313
|
+
sync_enabled: $input("syncEnabled")?.checked || false,
|
|
3314
|
+
sync_host: normalizeTextValue($input("syncHost")?.value || ""),
|
|
3315
|
+
sync_port: Number($input("syncPort")?.value || 0) || "",
|
|
3316
|
+
sync_interval_s: Number($input("syncInterval")?.value || 0) || "",
|
|
3317
|
+
sync_mdns: $input("syncMdns")?.checked || false,
|
|
3318
|
+
sync_coordinator_url: normalizeTextValue($input("syncCoordinatorUrl")?.value || ""),
|
|
3319
|
+
sync_coordinator_group: normalizeTextValue($input("syncCoordinatorGroup")?.value || ""),
|
|
3320
|
+
sync_coordinator_timeout_s: Number($input("syncCoordinatorTimeout")?.value || 0) || "",
|
|
3321
|
+
sync_coordinator_presence_ttl_s: Number($input("syncCoordinatorPresenceTtl")?.value || 0) || ""
|
|
3322
|
+
};
|
|
3323
|
+
}
|
|
3324
|
+
function updateAuthSourceVisibility() {
|
|
3325
|
+
const source = $select("observerAuthSource")?.value || "auto";
|
|
3326
|
+
const fileField = document.getElementById("observerAuthFileField");
|
|
3327
|
+
const commandField = document.getElementById("observerAuthCommandField");
|
|
3328
|
+
const commandNote = document.getElementById("observerAuthCommandNote");
|
|
3329
|
+
if (fileField) fileField.hidden = source !== "file";
|
|
3330
|
+
if (commandField) commandField.hidden = source !== "command";
|
|
3331
|
+
if (commandNote) commandNote.hidden = source !== "command";
|
|
3332
|
+
}
|
|
3333
|
+
function setSettingsTab(tab) {
|
|
3334
|
+
const next = [
|
|
3335
|
+
"observer",
|
|
3336
|
+
"queue",
|
|
3337
|
+
"sync"
|
|
3338
|
+
].includes(tab) ? tab : "observer";
|
|
3339
|
+
settingsActiveTab = next;
|
|
3340
|
+
document.querySelectorAll("[data-settings-tab]").forEach((node) => {
|
|
3341
|
+
const button = node;
|
|
3342
|
+
const active = button.dataset.settingsTab === next;
|
|
3343
|
+
button.classList.toggle("active", active);
|
|
3344
|
+
button.setAttribute("aria-selected", active ? "true" : "false");
|
|
3345
|
+
});
|
|
3346
|
+
document.querySelectorAll("[data-settings-panel]").forEach((node) => {
|
|
3347
|
+
const panel = node;
|
|
3348
|
+
const active = panel.dataset.settingsPanel === next;
|
|
3349
|
+
panel.classList.toggle("active", active);
|
|
3350
|
+
panel.hidden = !active;
|
|
3351
|
+
});
|
|
3352
|
+
}
|
|
3353
|
+
function setDirty(dirty) {
|
|
3354
|
+
state.settingsDirty = dirty;
|
|
3355
|
+
const saveBtn = $button("settingsSave");
|
|
3356
|
+
if (saveBtn) saveBtn.disabled = !dirty;
|
|
3357
|
+
}
|
|
3358
|
+
function openSettings(stopPolling) {
|
|
3359
|
+
settingsOpen = true;
|
|
3360
|
+
previouslyFocused = document.activeElement;
|
|
3361
|
+
stopPolling();
|
|
3362
|
+
show($("settingsBackdrop"));
|
|
3363
|
+
show($("settingsModal"));
|
|
3364
|
+
const modal = $("settingsModal");
|
|
3365
|
+
(getFocusableNodes(modal)[0] || modal)?.focus();
|
|
3366
|
+
}
|
|
3367
|
+
function closeSettings(startPolling, refreshCallback) {
|
|
3368
|
+
if (state.settingsDirty) {
|
|
3369
|
+
if (!globalThis.confirm("Discard unsaved changes?")) return;
|
|
3370
|
+
}
|
|
3371
|
+
settingsOpen = false;
|
|
3372
|
+
hide($("settingsBackdrop"));
|
|
3373
|
+
hide($("settingsModal"));
|
|
3374
|
+
hideHelpTooltip();
|
|
3375
|
+
(previouslyFocused && typeof previouslyFocused.focus === "function" ? previouslyFocused : $button("settingsButton"))?.focus();
|
|
3376
|
+
previouslyFocused = null;
|
|
3377
|
+
settingsTouchedKeys = /* @__PURE__ */ new Set();
|
|
3378
|
+
startPolling();
|
|
3379
|
+
refreshCallback();
|
|
3380
|
+
}
|
|
3381
|
+
async function saveSettings(startPolling, refreshCallback) {
|
|
3382
|
+
const saveBtn = $button("settingsSave");
|
|
3383
|
+
const status = $("settingsStatus");
|
|
3384
|
+
if (!saveBtn || !status) return;
|
|
3385
|
+
saveBtn.disabled = true;
|
|
3386
|
+
status.textContent = "Saving...";
|
|
3387
|
+
try {
|
|
3388
|
+
const current = collectSettingsPayload({ allowUntouchedParseErrors: true });
|
|
3389
|
+
const changed = {};
|
|
3390
|
+
Object.entries(current).forEach(([key, value]) => {
|
|
3391
|
+
if (hasOwn(settingsEnvOverrides, key) && !settingsTouchedKeys.has(key)) return;
|
|
3392
|
+
if (!isEqualValue(value, settingsBaseline[key])) changed[key] = value;
|
|
3393
|
+
});
|
|
3394
|
+
if (Object.keys(changed).length === 0) {
|
|
3395
|
+
status.textContent = "No changes";
|
|
3396
|
+
setDirty(false);
|
|
3397
|
+
closeSettings(startPolling, refreshCallback);
|
|
3398
|
+
return;
|
|
3399
|
+
}
|
|
3400
|
+
const notice = buildSettingsNotice(await saveConfig(changed));
|
|
3401
|
+
status.textContent = "Saved";
|
|
3402
|
+
setDirty(false);
|
|
3403
|
+
closeSettings(startPolling, refreshCallback);
|
|
3404
|
+
showGlobalNotice(notice.message, notice.type);
|
|
3405
|
+
} catch (error) {
|
|
3406
|
+
status.textContent = `Save failed: ${error instanceof Error ? error.message : "unknown error"}`;
|
|
3407
|
+
} finally {
|
|
3408
|
+
saveBtn.disabled = !state.settingsDirty;
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
function formatAuthMethod(method) {
|
|
3412
|
+
switch (method) {
|
|
3413
|
+
case "anthropic_consumer": return "OAuth (Claude Max/Pro)";
|
|
3414
|
+
case "codex_consumer": return "OAuth (ChatGPT subscription)";
|
|
3415
|
+
case "sdk_client": return "API key";
|
|
3416
|
+
case "claude_sidecar": return "Local Claude session";
|
|
3417
|
+
case "opencode_run": return "OpenCode sidecar";
|
|
3418
|
+
default: return method || "none";
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
function formatCredentialSources(creds) {
|
|
3422
|
+
const parts = [];
|
|
3423
|
+
if (creds.oauth) parts.push("OAuth");
|
|
3424
|
+
if (creds.api_key) parts.push("API key");
|
|
3425
|
+
if (creds.env_var) parts.push("env var");
|
|
3426
|
+
return parts.length ? parts.join(", ") : "none";
|
|
3427
|
+
}
|
|
3428
|
+
function createEl(tag, className, text) {
|
|
3429
|
+
const el = document.createElement(tag);
|
|
3430
|
+
if (className) el.className = className;
|
|
3431
|
+
if (text) el.textContent = text;
|
|
3432
|
+
return el;
|
|
3433
|
+
}
|
|
3434
|
+
function formatFailureTimestamp(value) {
|
|
3435
|
+
if (typeof value !== "string" || !value.trim()) return "Unknown time";
|
|
3436
|
+
const ts = new Date(value);
|
|
3437
|
+
if (Number.isNaN(ts.getTime())) return value;
|
|
3438
|
+
return ts.toLocaleString();
|
|
3439
|
+
}
|
|
3440
|
+
function renderObserverStatusBanner(status) {
|
|
3441
|
+
const banner = $("observerStatusBanner");
|
|
3442
|
+
if (!banner) return;
|
|
3443
|
+
if (!status || typeof status !== "object") {
|
|
3444
|
+
banner.hidden = true;
|
|
3445
|
+
return;
|
|
3446
|
+
}
|
|
3447
|
+
banner.textContent = "";
|
|
3448
|
+
const active = status.active;
|
|
3449
|
+
const available = status.available_credentials || {};
|
|
3450
|
+
if (active) {
|
|
3451
|
+
const provider = String(active.provider || "unknown");
|
|
3452
|
+
const model = String(active.model || "");
|
|
3453
|
+
const method = formatAuthMethod(active.auth?.method || "none");
|
|
3454
|
+
const tokenOk = active.auth?.token_present === true;
|
|
3455
|
+
banner.append(createEl("div", "status-label", "Active observer"));
|
|
3456
|
+
const row = createEl("div", "status-active");
|
|
3457
|
+
row.textContent = `${provider} \u2192 ${model} via ${method} `;
|
|
3458
|
+
const tokenSpan = createEl("span", tokenOk ? "cred-ok" : "cred-none", tokenOk ? "✓" : "✗");
|
|
3459
|
+
row.append(tokenSpan);
|
|
3460
|
+
banner.append(row);
|
|
3461
|
+
} else {
|
|
3462
|
+
banner.append(createEl("div", "status-label", "Observer status"));
|
|
3463
|
+
banner.append(createEl("div", "status-active", "Not yet initialized (waiting for first session)"));
|
|
3464
|
+
}
|
|
3465
|
+
const credEntries = Object.entries(available).filter(([, creds]) => creds && typeof creds === "object");
|
|
3466
|
+
if (credEntries.length) {
|
|
3467
|
+
banner.append(createEl("div", "status-label", "Available credentials"));
|
|
3468
|
+
const row = createEl("div");
|
|
3469
|
+
credEntries.forEach(([provider, creds], idx) => {
|
|
3470
|
+
const c = creds;
|
|
3471
|
+
const sources = formatCredentialSources(c);
|
|
3472
|
+
const hasAny = Object.values(c).some(Boolean);
|
|
3473
|
+
const span = createEl("span", "status-cred");
|
|
3474
|
+
const icon = createEl("span", hasAny ? "cred-ok" : "cred-none", hasAny ? "✓" : "–");
|
|
3475
|
+
span.append(icon);
|
|
3476
|
+
span.append(` ${String(provider)}: ${sources}`);
|
|
3477
|
+
if (idx > 0) row.append(" · ");
|
|
3478
|
+
row.append(span);
|
|
3479
|
+
});
|
|
3480
|
+
banner.append(row);
|
|
3481
|
+
}
|
|
3482
|
+
const failure = status.latest_failure;
|
|
3483
|
+
if (failure && typeof failure === "object") {
|
|
3484
|
+
banner.append(createEl("div", "status-label", "Latest processing issue"));
|
|
3485
|
+
const box = createEl("div", "status-issue");
|
|
3486
|
+
const message = typeof failure.error_message === "string" && failure.error_message.trim() ? failure.error_message.trim() : "Raw-event processing failed.";
|
|
3487
|
+
box.append(createEl("div", "status-issue-message", message));
|
|
3488
|
+
const detailParts = [];
|
|
3489
|
+
const provider = typeof failure.observer_provider === "string" ? failure.observer_provider.trim() : "";
|
|
3490
|
+
const model = typeof failure.observer_model === "string" ? failure.observer_model.trim() : "";
|
|
3491
|
+
const runtime = typeof failure.observer_runtime === "string" ? failure.observer_runtime.trim() : "";
|
|
3492
|
+
if (provider || model || runtime) {
|
|
3493
|
+
const flow = [
|
|
3494
|
+
provider || "observer",
|
|
3495
|
+
model ? `→ ${model}` : "",
|
|
3496
|
+
runtime ? `(${runtime})` : ""
|
|
3497
|
+
].filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
|
|
3498
|
+
if (flow) detailParts.push(flow);
|
|
3499
|
+
}
|
|
3500
|
+
const failedAt = formatFailureTimestamp(failure.updated_at);
|
|
3501
|
+
if (failedAt) detailParts.push(`Last failure ${failedAt}`);
|
|
3502
|
+
if (typeof failure.attempt_count === "number" && Number.isFinite(failure.attempt_count)) detailParts.push(`Attempts ${failure.attempt_count}`);
|
|
3503
|
+
if (detailParts.length) box.append(createEl("div", "status-issue-meta", detailParts.join(" · ")));
|
|
3504
|
+
const impact = typeof failure.impact === "string" ? failure.impact.trim() : "";
|
|
3505
|
+
if (impact) box.append(createEl("div", "status-issue-impact", impact));
|
|
3506
|
+
banner.append(box);
|
|
3507
|
+
}
|
|
3508
|
+
banner.hidden = false;
|
|
3509
|
+
}
|
|
3510
|
+
async function loadConfigData() {
|
|
3511
|
+
if (settingsOpen) return;
|
|
3512
|
+
try {
|
|
3513
|
+
const [payload, status] = await Promise.all([loadConfig(), loadObserverStatus().catch(() => null)]);
|
|
3514
|
+
renderConfigModal(payload);
|
|
3515
|
+
renderObserverStatusBanner(status);
|
|
3516
|
+
} catch {}
|
|
3517
|
+
}
|
|
3518
|
+
function initSettings(stopPolling, startPolling, refreshCallback) {
|
|
3519
|
+
const settingsButton = $button("settingsButton");
|
|
3520
|
+
const settingsClose = $button("settingsClose");
|
|
3521
|
+
const settingsBackdrop = $("settingsBackdrop");
|
|
3522
|
+
const settingsModal = $("settingsModal");
|
|
3523
|
+
const settingsSave = $button("settingsSave");
|
|
3524
|
+
settingsButton?.addEventListener("click", () => openSettings(stopPolling));
|
|
3525
|
+
settingsClose?.addEventListener("click", () => closeSettings(startPolling, refreshCallback));
|
|
3526
|
+
settingsBackdrop?.addEventListener("click", () => closeSettings(startPolling, refreshCallback));
|
|
3527
|
+
settingsModal?.addEventListener("click", (e) => {
|
|
3528
|
+
if (e.target === settingsModal) closeSettings(startPolling, refreshCallback);
|
|
3529
|
+
});
|
|
3530
|
+
settingsSave?.addEventListener("click", () => saveSettings(startPolling, refreshCallback));
|
|
3531
|
+
bindHelpTooltips();
|
|
3532
|
+
document.addEventListener("keydown", (e) => {
|
|
3533
|
+
trapModalFocus(e);
|
|
3534
|
+
if (e.key === "Escape" && settingsOpen) closeSettings(startPolling, refreshCallback);
|
|
3535
|
+
});
|
|
3536
|
+
[
|
|
3537
|
+
"claudeCommand",
|
|
3538
|
+
"observerProvider",
|
|
3539
|
+
"observerModel",
|
|
3540
|
+
"observerRuntime",
|
|
3541
|
+
"observerAuthSource",
|
|
3542
|
+
"observerAuthFile",
|
|
3543
|
+
"observerAuthCommand",
|
|
3544
|
+
"observerAuthTimeoutMs",
|
|
3545
|
+
"observerAuthCacheTtlS",
|
|
3546
|
+
"observerHeaders",
|
|
3547
|
+
"observerMaxChars",
|
|
3548
|
+
"packObservationLimit",
|
|
3549
|
+
"packSessionLimit",
|
|
3550
|
+
"rawEventsSweeperIntervalS",
|
|
3551
|
+
"syncEnabled",
|
|
3552
|
+
"syncHost",
|
|
3553
|
+
"syncPort",
|
|
3554
|
+
"syncInterval",
|
|
3555
|
+
"syncMdns",
|
|
3556
|
+
"syncCoordinatorUrl",
|
|
3557
|
+
"syncCoordinatorGroup",
|
|
3558
|
+
"syncCoordinatorTimeout",
|
|
3559
|
+
"syncCoordinatorPresenceTtl"
|
|
3560
|
+
].forEach((id) => {
|
|
3561
|
+
const input = document.getElementById(id);
|
|
3562
|
+
if (!input) return;
|
|
3563
|
+
input.addEventListener("input", () => {
|
|
3564
|
+
markFieldTouched(id);
|
|
3565
|
+
setDirty(true);
|
|
3566
|
+
});
|
|
3567
|
+
input.addEventListener("change", () => {
|
|
3568
|
+
markFieldTouched(id);
|
|
3569
|
+
setDirty(true);
|
|
3570
|
+
});
|
|
3571
|
+
});
|
|
3572
|
+
$select("observerAuthSource")?.addEventListener("change", () => updateAuthSourceVisibility());
|
|
3573
|
+
$select("observerProvider")?.addEventListener("change", () => renderObserverModelHint());
|
|
3574
|
+
$select("observerRuntime")?.addEventListener("change", () => renderObserverModelHint());
|
|
3575
|
+
$input("observerModel")?.addEventListener("input", () => renderObserverModelHint());
|
|
3576
|
+
$input("settingsAdvancedToggle")?.addEventListener("change", () => {
|
|
3577
|
+
const checked = Boolean($input("settingsAdvancedToggle")?.checked);
|
|
3578
|
+
setAdvancedVisibility(checked);
|
|
3579
|
+
persistAdvancedPreference(checked);
|
|
3580
|
+
});
|
|
3581
|
+
document.querySelectorAll("[data-settings-tab]").forEach((node) => {
|
|
3582
|
+
node.addEventListener("click", () => {
|
|
3583
|
+
setSettingsTab(node.dataset.settingsTab || "observer");
|
|
3584
|
+
});
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
3587
|
+
//#endregion
|
|
3588
|
+
//#region src/app.ts
|
|
3589
|
+
var lastAnnouncedRefreshState = null;
|
|
3590
|
+
function setRefreshStatus(rs, detail) {
|
|
3591
|
+
state.refreshState = rs;
|
|
3592
|
+
const el = $("refreshStatus");
|
|
3593
|
+
if (!el) return;
|
|
3594
|
+
const announce = (msg) => {
|
|
3595
|
+
const announcer = $("refreshAnnouncer");
|
|
3596
|
+
if (!announcer || lastAnnouncedRefreshState === rs) return;
|
|
3597
|
+
announcer.textContent = msg;
|
|
3598
|
+
lastAnnouncedRefreshState = rs;
|
|
3599
|
+
};
|
|
3600
|
+
if (rs === "refreshing") {
|
|
3601
|
+
el.textContent = "refreshing…";
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
if (rs === "paused") {
|
|
3605
|
+
el.textContent = "paused";
|
|
3606
|
+
announce("Auto refresh paused.");
|
|
3607
|
+
return;
|
|
3608
|
+
}
|
|
3609
|
+
if (rs === "error") {
|
|
3610
|
+
el.textContent = "refresh failed";
|
|
3611
|
+
announce("Refresh failed.");
|
|
3612
|
+
return;
|
|
3613
|
+
}
|
|
3614
|
+
const suffix = detail ? ` ${detail}` : "";
|
|
3615
|
+
el.textContent = "updated " + (/* @__PURE__ */ new Date()).toLocaleTimeString() + suffix;
|
|
3616
|
+
lastAnnouncedRefreshState = null;
|
|
3617
|
+
}
|
|
3618
|
+
function stopPolling() {
|
|
3619
|
+
if (state.refreshTimer) {
|
|
3620
|
+
clearInterval(state.refreshTimer);
|
|
3621
|
+
state.refreshTimer = null;
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
function startPolling() {
|
|
3625
|
+
if (state.refreshTimer) return;
|
|
3626
|
+
state.refreshTimer = setInterval(() => refresh(), 5e3);
|
|
3627
|
+
}
|
|
3628
|
+
document.addEventListener("visibilitychange", () => {
|
|
3629
|
+
if (document.visibilityState === "hidden") {
|
|
3630
|
+
stopPolling();
|
|
3631
|
+
setRefreshStatus("paused", "(tab hidden)");
|
|
3632
|
+
} else if (!isSettingsOpen()) {
|
|
3633
|
+
startPolling();
|
|
3634
|
+
refresh();
|
|
3635
|
+
}
|
|
3636
|
+
});
|
|
3637
|
+
var TAB_IDS = [
|
|
3638
|
+
"feed",
|
|
3639
|
+
"health",
|
|
3640
|
+
"sync"
|
|
3641
|
+
];
|
|
3642
|
+
function switchTab(tab) {
|
|
3643
|
+
setActiveTab(tab);
|
|
3644
|
+
TAB_IDS.forEach((id) => {
|
|
3645
|
+
const panel = $(`tab-${id}`);
|
|
3646
|
+
if (panel) panel.hidden = id !== tab;
|
|
3647
|
+
});
|
|
3648
|
+
TAB_IDS.forEach((id) => {
|
|
3649
|
+
const btn = $(`tabBtn-${id}`);
|
|
3650
|
+
if (btn) btn.classList.toggle("active", id === tab);
|
|
3651
|
+
});
|
|
3652
|
+
refresh();
|
|
3653
|
+
}
|
|
3654
|
+
function initTabs() {
|
|
3655
|
+
TAB_IDS.forEach((id) => {
|
|
3656
|
+
$(`tabBtn-${id}`)?.addEventListener("click", () => switchTab(id));
|
|
3657
|
+
});
|
|
3658
|
+
window.addEventListener("hashchange", () => {
|
|
3659
|
+
const hash = window.location.hash.replace("#", "");
|
|
3660
|
+
if (TAB_IDS.includes(hash) && hash !== state.activeTab) switchTab(hash);
|
|
3661
|
+
});
|
|
3662
|
+
switchTab(state.activeTab);
|
|
3663
|
+
}
|
|
3664
|
+
async function loadProjects() {
|
|
3665
|
+
try {
|
|
3666
|
+
const projects = await loadProjects$1();
|
|
3667
|
+
const projectFilter = $select("projectFilter");
|
|
3668
|
+
if (!projectFilter) return;
|
|
3669
|
+
projectFilter.textContent = "";
|
|
3670
|
+
const allOpt = document.createElement("option");
|
|
3671
|
+
allOpt.value = "";
|
|
3672
|
+
allOpt.textContent = "All Projects";
|
|
3673
|
+
projectFilter.appendChild(allOpt);
|
|
3674
|
+
projects.forEach((p) => {
|
|
3675
|
+
const opt = document.createElement("option");
|
|
3676
|
+
opt.value = p;
|
|
3677
|
+
opt.textContent = p;
|
|
3678
|
+
projectFilter.appendChild(opt);
|
|
3679
|
+
});
|
|
3680
|
+
} catch {}
|
|
3681
|
+
}
|
|
3682
|
+
$select("projectFilter")?.addEventListener("change", () => {
|
|
3683
|
+
state.currentProject = $select("projectFilter")?.value || "";
|
|
3684
|
+
refresh();
|
|
3685
|
+
});
|
|
3686
|
+
var refreshDebounceTimer = null;
|
|
3687
|
+
async function refresh() {
|
|
3688
|
+
if (refreshDebounceTimer) clearTimeout(refreshDebounceTimer);
|
|
3689
|
+
refreshDebounceTimer = setTimeout(() => doRefresh(), 80);
|
|
3690
|
+
}
|
|
3691
|
+
async function doRefresh() {
|
|
3692
|
+
if (state.refreshInFlight) {
|
|
3693
|
+
state.refreshQueued = true;
|
|
3694
|
+
return;
|
|
3695
|
+
}
|
|
3696
|
+
state.refreshInFlight = true;
|
|
3697
|
+
try {
|
|
3698
|
+
setRefreshStatus("refreshing");
|
|
3699
|
+
const promises = [loadHealthData(), loadConfigData()];
|
|
3700
|
+
if (state.activeTab === "feed") promises.push(loadFeedData());
|
|
3701
|
+
if (state.activeTab === "sync" || state.activeTab === "health") promises.push(loadSyncData());
|
|
3702
|
+
if (state.syncPairingOpen) promises.push(loadPairingData());
|
|
3703
|
+
await Promise.all(promises);
|
|
3704
|
+
setRefreshStatus("idle");
|
|
3705
|
+
} catch {
|
|
3706
|
+
setRefreshStatus("error");
|
|
3707
|
+
} finally {
|
|
3708
|
+
state.refreshInFlight = false;
|
|
3709
|
+
if (state.refreshQueued) {
|
|
3710
|
+
state.refreshQueued = false;
|
|
3711
|
+
doRefresh();
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
initState();
|
|
3716
|
+
initThemeSelect($select("themeSelect"));
|
|
3717
|
+
setTheme(getTheme());
|
|
3718
|
+
initTabs();
|
|
3719
|
+
initFeedTab();
|
|
3720
|
+
initSyncTab(() => refresh());
|
|
3721
|
+
initSettings(stopPolling, startPolling, () => refresh());
|
|
3722
|
+
loadProjects();
|
|
3723
|
+
refresh();
|
|
3724
|
+
startPolling();
|
|
3725
|
+
//#endregion
|
|
3726
|
+
})();
|