@oar-sh/oar 0.9.7 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +89 -40
  3. package/bin/oar.js +1023 -135
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  7. package/server/claude-worker/claude-session-process.mjs +196 -28
  8. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  9. package/server/db-schema.mjs +8 -0
  10. package/server/migrations/0008-message-pins.mjs +85 -0
  11. package/server/public/app/activity-replay-state.mjs +68 -5
  12. package/server/public/app/api-client.js +36 -2
  13. package/server/public/app/attachments-view.js +508 -125
  14. package/server/public/app/back-guard.mjs +58 -0
  15. package/server/public/app/bootstrap.js +170 -59
  16. package/server/public/app/context-usage-view.mjs +127 -8
  17. package/server/public/app/conversation-view.js +172 -32
  18. package/server/public/app/external-link-policy.mjs +4 -1
  19. package/server/public/app/file-version.mjs +58 -0
  20. package/server/public/app/journal-view.js +16 -2
  21. package/server/public/app/pinned-messages-view.mjs +253 -0
  22. package/server/public/app/pwa-install.js +6 -1
  23. package/server/public/app/relay-dot-state.mjs +8 -1
  24. package/server/public/app/router.js +8 -2
  25. package/server/public/app/scrollbar-hover.mjs +78 -0
  26. package/server/public/app/settings-modal.js +4 -1
  27. package/server/public/app/socket-handlers.js +8 -1
  28. package/server/public/app/store.js +32 -0
  29. package/server/public/app/transcript-separators.mjs +48 -11
  30. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  31. package/server/public/app/viewer-controls-state.mjs +59 -0
  32. package/server/public/app/viewer-gallery.mjs +46 -0
  33. package/server/public/app/viewer-gestures.mjs +105 -0
  34. package/server/public/app/viewer-history.mjs +43 -0
  35. package/server/public/app/viewer-tap.mjs +112 -0
  36. package/server/public/app-icon-192.png +0 -0
  37. package/server/public/app-icon-512.png +0 -0
  38. package/server/public/app-icon-light-192.png +0 -0
  39. package/server/public/app-icon-light-512.png +0 -0
  40. package/server/public/app-icon-light-maskable-512.png +0 -0
  41. package/server/public/app-icon-light.svg +4 -0
  42. package/server/public/app-icon-maskable-512.png +0 -0
  43. package/server/public/app-icon.svg +3 -3
  44. package/server/public/favicon.ico +0 -0
  45. package/server/public/index.html +487 -67
  46. package/server/public/manifest.webmanifest +9 -3
  47. package/server/public/sw.js +6 -1
  48. package/server/repositories/message-pin-statements.mjs +39 -0
  49. package/server/repositories/message-repository.mjs +5 -0
  50. package/server/routes/messages-routes.mjs +36 -62
  51. package/server/routes/sessions-routes.mjs +107 -26
  52. package/server/server-runtime.mjs +39 -8
  53. package/server/services/claude-context-usage.mjs +51 -3
  54. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  55. package/server/services/file-serving.mjs +155 -0
  56. package/server/services/message-pin-list.mjs +52 -0
  57. package/server/services/oar-cli-helpers.mjs +205 -4
  58. package/server/services/oar-cli-menu.mjs +240 -0
  59. package/server/services/oar-cli-wsl.mjs +129 -0
  60. package/server/services/pwa-manifest-theme.mjs +30 -0
  61. package/server/services/safe-served-content.mjs +61 -11
  62. package/server/services/session-history-refresh-service.mjs +24 -0
  63. package/server/services/session-worker-registry-service.mjs +18 -1
  64. package/server/services/update-install-service.mjs +9 -3
@@ -18,10 +18,44 @@ export function normalizeRelayActivityEntry(item) {
18
18
  // A compaction boundary is published as an ordinary activity row carrying
19
19
  // structured metadata. The transcript promotes it to a full-width break row
20
20
  // (and drops it from the bubble's tool-activity list), so both sides ask here.
21
+ // Every compaction entry answers here, whatever its state: the live bubble
22
+ // never shows any of them as prose.
21
23
  export function isCompactBoundaryActivityEntry(item) {
22
24
  return normalizeRelayActivityEntry(item)?.metadata?.kind === 'compact_boundary';
23
25
  }
24
26
 
27
+ // The live list of one turn keeps its newest ordinary lines only, but every
28
+ // structured row a line of the transcript is built from: the turn's
29
+ // compaction entries and the session-restart note would otherwise scroll out
30
+ // of a busy turn and take their line with them until the next reload.
31
+ export const LIVE_ACTIVITY_CAP = 24;
32
+ function isKeptLiveActivityEntry(item) {
33
+ const kind = normalizeRelayActivityEntry(item)?.metadata?.kind;
34
+ return kind === 'compact_boundary' || kind === 'compact_window_respawn';
35
+ }
36
+ export function capLiveActivityEntries(items, cap = LIVE_ACTIVITY_CAP) {
37
+ const list = Array.isArray(items) ? items : [];
38
+ let ordinary = list.reduce((count, item) => count + (isKeptLiveActivityEntry(item) ? 0 : 1), 0);
39
+ if (ordinary <= cap) return list;
40
+ return list.filter((item) => {
41
+ if (isKeptLiveActivityEntry(item)) return true;
42
+ ordinary -= 1;
43
+ return ordinary < cap;
44
+ });
45
+ }
46
+
47
+ // A compaction is published in up to two steps on the same message: a
48
+ // `pending` entry when it starts, then either the boundary (no `state`, token
49
+ // counts) or a `cancelled` entry when it ended without a result.
50
+ // → 'pending' | 'cancelled' | 'boundary' | null (not a compaction entry).
51
+ export function compactionEntryState(item) {
52
+ const metadata = normalizeRelayActivityEntry(item)?.metadata;
53
+ if (metadata?.kind !== 'compact_boundary') return null;
54
+ const state = String(metadata.state || '').trim().toLowerCase();
55
+ if (state === 'pending' || state === 'cancelled') return state;
56
+ return 'boundary';
57
+ }
58
+
25
59
  function toTokenCount(value) {
26
60
  // `Number(null)` is 0, not NaN: without this an omitted post_tokens (every
27
61
  // real auto-compaction) would read as a compaction down to zero tokens.
@@ -34,19 +68,36 @@ function toTokenCount(value) {
34
68
  // than once only the LAST boundary is promoted. This returns that entry by
35
69
  // identity, so the render path can keep the others visible as prose instead
36
70
  // of dropping them (they would otherwise vanish from the transcript).
37
- export function promotedCompactBoundaryEntry(items) {
71
+ //
72
+ // The last compaction entry decides: a `pending` one is promoted only while
73
+ // the message is still `processing` (a finished or failed message never shows
74
+ // a dangling start). A dangling start or a `cancelled` end draws no line of
75
+ // its own, so the line falls back to an earlier boundary of the same message
76
+ // when there is one.
77
+ export function promotedCompactBoundaryEntry(items, { processing = false } = {}) {
38
78
  const list = Array.isArray(items) ? items : [];
79
+ let sawLast = false;
39
80
  for (let index = list.length - 1; index >= 0; index -= 1) {
40
- if (isCompactBoundaryActivityEntry(list[index])) return list[index];
81
+ const state = compactionEntryState(list[index]);
82
+ if (!state) continue;
83
+ if (!sawLast) {
84
+ sawLast = true;
85
+ if (state === 'pending' && processing) return list[index];
86
+ }
87
+ if (state === 'boundary') return list[index];
41
88
  }
42
89
  return null;
43
90
  }
44
91
 
45
92
  // The last compaction recorded against one message's activities, as
46
- // { preTokens, postTokens } (either may be null when the SDK omitted it).
47
- export function compactBoundaryFromActivities(items) {
48
- const promoted = promotedCompactBoundaryEntry(items);
93
+ // { preTokens, postTokens } (either may be null when the SDK omitted it), or
94
+ // { pending: true, preTokens: null, postTokens: null } while it runs.
95
+ export function compactBoundaryFromActivities(items, options = {}) {
96
+ const promoted = promotedCompactBoundaryEntry(items, options);
49
97
  if (!promoted) return null;
98
+ if (compactionEntryState(promoted) === 'pending') {
99
+ return { pending: true, preTokens: null, postTokens: null };
100
+ }
50
101
  const metadata = normalizeRelayActivityEntry(promoted).metadata;
51
102
  return {
52
103
  preTokens: toTokenCount(metadata.preTokens),
@@ -54,6 +105,18 @@ export function compactBoundaryFromActivities(items) {
54
105
  };
55
106
  }
56
107
 
108
+ // Activity rows the message bubble lists as prose: everything except the
109
+ // promoted boundary (it is the break row) and every pending/cancelled step
110
+ // (transient state, never prose).
111
+ export function visibleActivityEntries(items, promoted) {
112
+ const list = Array.isArray(items) ? items : [];
113
+ return list.filter((item) => {
114
+ if (item === promoted) return false;
115
+ const state = compactionEntryState(item);
116
+ return state !== 'pending' && state !== 'cancelled';
117
+ });
118
+ }
119
+
57
120
  // Head-cap that never drops structured rows. Activity lists are capped from
58
121
  // the front (the start of a turn is the interesting part), but a
59
122
  // metadata-bearing row carries transcript structure rather than prose, and a
@@ -46,7 +46,9 @@ export function areNetworkRequestsEnabled() {
46
46
  }
47
47
 
48
48
  export async function apiFetch(url, opts = {}) {
49
- if (!networkRequestsEnabled) return null;
49
+ // A keepalive request is a page's last word; it goes out even when the
50
+ // page's regular network work is already suspended.
51
+ if (!networkRequestsEnabled && !opts.keepalive) return null;
50
52
  try {
51
53
  const response = await fetch(`${BASE}${url}`, {
52
54
  signal: requestTimeoutSignal(),
@@ -898,6 +900,36 @@ export async function updateMessageShareVisibility(conversationId, messageId, hi
898
900
  });
899
901
  }
900
902
 
903
+ // Pin or unpin one message. Unlike apiFetch this keeps the relay's `error`
904
+ // text on a refusal (the pin limit), so the notice can say why.
905
+ export async function updateMessagePin(conversationId, messageId, pinned) {
906
+ const convId = String(conversationId || '').trim();
907
+ const msgId = String(messageId || '').trim();
908
+ if (!convId || !msgId || !networkRequestsEnabled) return null;
909
+ const url = `/api/conversation/${encodeURIComponent(convId)}/message/${encodeURIComponent(msgId)}/pin`;
910
+ try {
911
+ const response = await fetch(`${BASE}${url}`, {
912
+ signal: requestTimeoutSignal(),
913
+ method: 'PATCH',
914
+ headers: {
915
+ 'Content-Type': 'application/json',
916
+ ...authHeaders(),
917
+ },
918
+ body: JSON.stringify({ pinned: pinned === true }),
919
+ });
920
+ const result = await response.json().catch(() => null);
921
+ if (!response.ok) {
922
+ console.error('API error', response.status, url);
923
+ return { ok: false, status: response.status, error: String(result?.error || '').trim() };
924
+ }
925
+ noteFetchSuccess();
926
+ return result;
927
+ } catch (error) {
928
+ noteFetchFailure(url, error);
929
+ return null;
930
+ }
931
+ }
932
+
901
933
  export async function updateConversationTitle(id, title) {
902
934
  const convId = String(id || '').trim();
903
935
  if (!convId) return null;
@@ -924,12 +956,14 @@ export async function updateWorkspaceRootAttribution(path, attributionMode) {
924
956
  });
925
957
  }
926
958
 
927
- export async function updateConversationPreferences(id, preferences = {}) {
959
+ // `keepalive` lets the request outlive the page (a save made while it closes).
960
+ export async function updateConversationPreferences(id, preferences = {}, { keepalive = false } = {}) {
928
961
  const convId = String(id || '').trim();
929
962
  if (!convId) return null;
930
963
  return apiFetch(`/api/conversation/${convId}/preferences`, {
931
964
  method: 'PATCH',
932
965
  body: JSON.stringify(preferences || {}),
966
+ ...(keepalive ? { keepalive: true } : {}),
933
967
  });
934
968
  }
935
969