@oar-sh/oar 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function createApiClient({ serverUrl, token, getHeaders }) {
|
|
2
|
+
return async function api(method, routePath, body) {
|
|
3
|
+
const url = `${serverUrl}${routePath}`;
|
|
4
|
+
const extraHeaders = typeof getHeaders === "function" ? (getHeaders() || {}) : {};
|
|
5
|
+
const opts = {
|
|
6
|
+
method,
|
|
7
|
+
headers: {
|
|
8
|
+
"Content-Type": "application/json",
|
|
9
|
+
Authorization: `Bearer ${token}`,
|
|
10
|
+
...extraHeaders,
|
|
11
|
+
},
|
|
12
|
+
...(method !== "GET" ? { body: JSON.stringify(body || {}) } : {}),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const res = await fetch(url, opts);
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
const rawText = String(await res.text().catch(() => "")).trim();
|
|
18
|
+
let detail = rawText;
|
|
19
|
+
if (rawText) {
|
|
20
|
+
try {
|
|
21
|
+
const payload = JSON.parse(rawText);
|
|
22
|
+
detail = String(payload?.error || payload?.message || rawText).trim();
|
|
23
|
+
} catch {
|
|
24
|
+
detail = rawText;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const error = new Error(`HTTP ${res.status} ${routePath}${detail ? `: ${detail}` : ""}`);
|
|
28
|
+
error.status = res.status;
|
|
29
|
+
error.detail = detail;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
return res.json();
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
function normalizePositiveMs(value, fallback) {
|
|
5
|
+
const num = Number(value);
|
|
6
|
+
if (!Number.isFinite(num) || num <= 0) return fallback;
|
|
7
|
+
return Math.max(1000, Math.floor(num));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stableHash(text) {
|
|
11
|
+
const input = String(text || "");
|
|
12
|
+
let hash = 0x811c9dc5;
|
|
13
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
14
|
+
hash ^= input.charCodeAt(i);
|
|
15
|
+
hash = Math.imul(hash, 0x01000193);
|
|
16
|
+
}
|
|
17
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nowMs() {
|
|
21
|
+
return Date.now();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createBannerStateStore({
|
|
25
|
+
stateFilePath,
|
|
26
|
+
dbg = () => {},
|
|
27
|
+
ttlMs = 6 * 60 * 60 * 1000,
|
|
28
|
+
cooldownMs = 15 * 1000,
|
|
29
|
+
} = {}) {
|
|
30
|
+
const ttl = normalizePositiveMs(ttlMs, 6 * 60 * 60 * 1000);
|
|
31
|
+
const cooldown = normalizePositiveMs(cooldownMs, 15 * 1000);
|
|
32
|
+
const inMemorySeen = new Map();
|
|
33
|
+
let loaded = false;
|
|
34
|
+
let persistedState = { version: 1, entries: {}, updatedAt: 0 };
|
|
35
|
+
|
|
36
|
+
function tokenFingerprint(token) {
|
|
37
|
+
return stableHash(String(token || ""));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildEntryKey({ sessionId, bannerKey, token }) {
|
|
41
|
+
const sessionPart = String(sessionId || "unknown");
|
|
42
|
+
const bannerPart = stableHash(String(bannerKey || ""));
|
|
43
|
+
const tokenPart = tokenFingerprint(token);
|
|
44
|
+
return `${sessionPart}|${tokenPart}|${bannerPart}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pruneEntries(state, currentNow = nowMs()) {
|
|
48
|
+
const entries = state?.entries && typeof state.entries === "object" ? state.entries : {};
|
|
49
|
+
let changed = false;
|
|
50
|
+
for (const [key, seenAt] of Object.entries(entries)) {
|
|
51
|
+
const ts = Number(seenAt);
|
|
52
|
+
if (!Number.isFinite(ts) || ts <= 0 || (currentNow - ts) > ttl) {
|
|
53
|
+
delete entries[key];
|
|
54
|
+
changed = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
state.entries = entries;
|
|
58
|
+
if (changed) state.updatedAt = currentNow;
|
|
59
|
+
return changed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadState() {
|
|
63
|
+
if (loaded) return;
|
|
64
|
+
loaded = true;
|
|
65
|
+
try {
|
|
66
|
+
const raw = fs.readFileSync(stateFilePath, "utf8");
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
if (parsed && typeof parsed === "object") {
|
|
69
|
+
persistedState = {
|
|
70
|
+
version: Number(parsed.version) || 1,
|
|
71
|
+
entries: parsed.entries && typeof parsed.entries === "object" ? parsed.entries : {},
|
|
72
|
+
updatedAt: Number(parsed.updatedAt) || 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
pruneEntries(persistedState);
|
|
76
|
+
} catch {
|
|
77
|
+
persistedState = { version: 1, entries: {}, updatedAt: 0 };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function saveState() {
|
|
82
|
+
try {
|
|
83
|
+
const dir = path.dirname(String(stateFilePath || ""));
|
|
84
|
+
if (dir) fs.mkdirSync(dir, { recursive: true });
|
|
85
|
+
fs.writeFileSync(stateFilePath, JSON.stringify(persistedState, null, 2), "utf8");
|
|
86
|
+
} catch (error) {
|
|
87
|
+
dbg("banner dedupe state write failed:", error?.message || String(error));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function shouldSuppress({ sessionId, bannerKey, token, force = false } = {}) {
|
|
92
|
+
if (force) return { suppress: false, reason: "forced" };
|
|
93
|
+
const key = buildEntryKey({ sessionId, bannerKey, token });
|
|
94
|
+
const currentNow = nowMs();
|
|
95
|
+
const memSeenAt = Number(inMemorySeen.get(key));
|
|
96
|
+
if (Number.isFinite(memSeenAt) && (currentNow - memSeenAt) < cooldown) {
|
|
97
|
+
return { suppress: true, reason: "cooldown", key };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
loadState();
|
|
101
|
+
pruneEntries(persistedState, currentNow);
|
|
102
|
+
const persistedSeenAt = Number(persistedState.entries[key]);
|
|
103
|
+
if (Number.isFinite(persistedSeenAt) && (currentNow - persistedSeenAt) < ttl) {
|
|
104
|
+
inMemorySeen.set(key, persistedSeenAt);
|
|
105
|
+
return { suppress: true, reason: "persisted", key };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { suppress: false, reason: "new", key };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function markShown({ sessionId, bannerKey, token } = {}) {
|
|
112
|
+
const key = buildEntryKey({ sessionId, bannerKey, token });
|
|
113
|
+
const currentNow = nowMs();
|
|
114
|
+
inMemorySeen.set(key, currentNow);
|
|
115
|
+
loadState();
|
|
116
|
+
persistedState.entries[key] = currentNow;
|
|
117
|
+
persistedState.updatedAt = currentNow;
|
|
118
|
+
pruneEntries(persistedState, currentNow);
|
|
119
|
+
saveState();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
shouldSuppress,
|
|
124
|
+
markShown,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
function toAbsolutePath(value) {
|
|
6
|
+
if (!value) return "";
|
|
7
|
+
return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function looksLikeServerDir(serverDir) {
|
|
11
|
+
if (!serverDir) return false;
|
|
12
|
+
return fs.existsSync(path.join(serverDir, "server.js")) && fs.existsSync(path.join(serverDir, "config.json"));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveRelayPaths(importMetaUrl) {
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(importMetaUrl));
|
|
17
|
+
const projectServerDir = path.resolve(__dirname, "../../../server");
|
|
18
|
+
const cwdServerDir = path.resolve(process.cwd(), "server");
|
|
19
|
+
const envRoot = toAbsolutePath(process.env.COPILOT_WEB_RELAY_ROOT || "");
|
|
20
|
+
const envServerDir = toAbsolutePath(process.env.COPILOT_WEB_RELAY_SERVER_DIR || "");
|
|
21
|
+
const envConfigPath = toAbsolutePath(process.env.COPILOT_WEB_RELAY_CONFIG || "");
|
|
22
|
+
const envToolsPath = toAbsolutePath(process.env.COPILOT_WEB_RELAY_TOOLS || "");
|
|
23
|
+
const envLogDir = toAbsolutePath(process.env.COPILOT_WEB_RELAY_LOG_DIR || "");
|
|
24
|
+
|
|
25
|
+
const resolvedServerDir =
|
|
26
|
+
(looksLikeServerDir(envServerDir) && envServerDir) ||
|
|
27
|
+
(looksLikeServerDir(path.join(envRoot, "server")) && path.join(envRoot, "server")) ||
|
|
28
|
+
(looksLikeServerDir(projectServerDir) && projectServerDir) ||
|
|
29
|
+
(looksLikeServerDir(cwdServerDir) && cwdServerDir) ||
|
|
30
|
+
projectServerDir;
|
|
31
|
+
|
|
32
|
+
const SERVER_DIR = resolvedServerDir;
|
|
33
|
+
const CONFIG_PATH = envConfigPath || path.resolve(SERVER_DIR, "config.json");
|
|
34
|
+
const RELAY_TOOLS_PATH = envToolsPath || path.resolve(SERVER_DIR, "relay-tools.md");
|
|
35
|
+
const LOG_DIR = envLogDir || path.resolve(SERVER_DIR, "logs");
|
|
36
|
+
const SERVER_LOG_PATH = path.resolve(LOG_DIR, "server.log");
|
|
37
|
+
const SERVER_ERR_PATH = path.resolve(LOG_DIR, "server-err.log");
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
__dirname,
|
|
41
|
+
CONFIG_PATH,
|
|
42
|
+
RELAY_TOOLS_PATH,
|
|
43
|
+
SERVER_DIR,
|
|
44
|
+
LOG_DIR,
|
|
45
|
+
SERVER_LOG_PATH,
|
|
46
|
+
SERVER_ERR_PATH,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadTokenFromConfig(configPath) {
|
|
51
|
+
try {
|
|
52
|
+
const cfg = loadRelayConfig(configPath);
|
|
53
|
+
return String(cfg.authToken || "").trim();
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function loadRelayConfig(configPath) {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
62
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeLoopbackHost(value) {
|
|
69
|
+
const host = String(value || "").trim();
|
|
70
|
+
if (!host || host === "0.0.0.0" || host === "::") return "127.0.0.1";
|
|
71
|
+
if (host === "localhost") return "127.0.0.1";
|
|
72
|
+
return host;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveRelayServerUrl({ configPath, env = process.env } = {}) {
|
|
76
|
+
const explicitUrl = String(env.COPILOT_WEB_RELAY_SERVER_URL || "").trim();
|
|
77
|
+
if (explicitUrl) {
|
|
78
|
+
const url = new URL(explicitUrl);
|
|
79
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
80
|
+
throw new Error(`invalid-relay-server-url:${url.protocol}`);
|
|
81
|
+
}
|
|
82
|
+
url.pathname = "";
|
|
83
|
+
url.search = "";
|
|
84
|
+
url.hash = "";
|
|
85
|
+
return url.toString().replace(/\/$/, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const config = loadRelayConfig(configPath);
|
|
89
|
+
const configuredPort = Number.parseInt(String(config.port || ""), 10);
|
|
90
|
+
const port = Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort <= 65535
|
|
91
|
+
? configuredPort
|
|
92
|
+
: 3333;
|
|
93
|
+
const host = normalizeLoopbackHost(config.host);
|
|
94
|
+
const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
95
|
+
return `http://${formattedHost}:${port}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function loadRelayInstructionsFromFile(relayToolsPath) {
|
|
99
|
+
try {
|
|
100
|
+
return String(fs.readFileSync(relayToolsPath, "utf8")).trim();
|
|
101
|
+
} catch {
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export function createDebugLogger({ logDir, fileName = "ext-debug.log", prefix = "[web-relay-dbg]" }) {
|
|
5
|
+
const debugLogPath = path.resolve(logDir, fileName);
|
|
6
|
+
|
|
7
|
+
function dbg(...args) {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const stamp = [
|
|
10
|
+
now.getUTCFullYear(),
|
|
11
|
+
String(now.getUTCMonth() + 1).padStart(2, "0"),
|
|
12
|
+
String(now.getUTCDate()).padStart(2, "0"),
|
|
13
|
+
String(now.getUTCHours()).padStart(2, "0"),
|
|
14
|
+
String(now.getUTCMinutes()).padStart(2, "0"),
|
|
15
|
+
String(now.getUTCSeconds()).padStart(2, "0"),
|
|
16
|
+
].join("-");
|
|
17
|
+
const line = `[${stamp}] ${args.join(" ")}\n`;
|
|
18
|
+
try { fs.mkdirSync(logDir, { recursive: true }); } catch {}
|
|
19
|
+
try { fs.appendFileSync(debugLogPath, line); } catch {}
|
|
20
|
+
process.stderr.write(`${prefix} ${line}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
dbg,
|
|
25
|
+
debugLogPath,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
function readErrorText(error) {
|
|
2
|
+
return String(error?.message || error || "").trim();
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function extractFirstMatch(text, pattern) {
|
|
6
|
+
const match = text.match(pattern);
|
|
7
|
+
return String(match?.[1] || "").trim() || null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function toKebabToken(value) {
|
|
11
|
+
return String(value || "")
|
|
12
|
+
.trim()
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
15
|
+
.replace(/^-+|-+$/g, "") || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The runtime's own vocabulary for "the billing window is spent". The Copilot
|
|
19
|
+
// SDK's `ErrorData.errorCode` documents `quota_exceeded` and
|
|
20
|
+
// `session_quota_exceeded`; `errorType` documents the coarser `quota`
|
|
21
|
+
// category; HTTP 402 is the transport-level form of the same thing.
|
|
22
|
+
const QUOTA_ERROR_CODES = new Set([
|
|
23
|
+
"quota_exceeded",
|
|
24
|
+
"session_quota_exceeded",
|
|
25
|
+
"quota-exceeded",
|
|
26
|
+
"session-quota-exceeded",
|
|
27
|
+
"quota",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True when a runtime flagged quota exhaustion STRUCTURALLY, independent of
|
|
32
|
+
* how the prose was worded. This exists because the prose is the one part
|
|
33
|
+
* GitHub rewords freely ("premium requests" → "AI credits" → "monthly quota"),
|
|
34
|
+
* and a quota failure misfiled as retryable burns the retry budget against a
|
|
35
|
+
* window that cannot reset until the next billing period.
|
|
36
|
+
*
|
|
37
|
+
* Accepts the loose shapes callers actually hold: an `Error` with `code` /
|
|
38
|
+
* `statusCode`, or a runtime error payload with `errorCode` / `errorType` /
|
|
39
|
+
* `statusCode`.
|
|
40
|
+
*/
|
|
41
|
+
export function isStructuredQuotaError(source) {
|
|
42
|
+
if (!source || typeof source !== "object") return false;
|
|
43
|
+
for (const field of ["code", "errorCode", "errorType"]) {
|
|
44
|
+
const token = String(source[field] || "").trim().toLowerCase();
|
|
45
|
+
if (token && QUOTA_ERROR_CODES.has(token)) return true;
|
|
46
|
+
}
|
|
47
|
+
const status = Number(source.statusCode ?? source.status);
|
|
48
|
+
return status === 402;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const QUOTA_EXHAUSTED = {
|
|
52
|
+
code: "quota-exhausted",
|
|
53
|
+
message: "GitHub Copilot has no AI credits left for this billing window, so the request was rejected.",
|
|
54
|
+
guidance: "Open Check Usage for the reset time and retry after the reset, or switch this conversation to another provider.",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `hint` carries structured signals that outrank the prose. Only quota is
|
|
59
|
+
* modelled today; the prose branches below are unchanged and remain the sole
|
|
60
|
+
* classifier when no hint is supplied, which is the extension's path.
|
|
61
|
+
*/
|
|
62
|
+
function classifyTerminalError(text, hint = {}) {
|
|
63
|
+
// Checked before the prose so a structurally-flagged quota error lands in
|
|
64
|
+
// the non-retryable branch however mildly it was worded.
|
|
65
|
+
if (hint.quota === true) return QUOTA_EXHAUSTED;
|
|
66
|
+
|
|
67
|
+
const lower = text.toLowerCase();
|
|
68
|
+
if (!lower) return null;
|
|
69
|
+
|
|
70
|
+
if (lower.includes("no tool output found for function call")) {
|
|
71
|
+
return {
|
|
72
|
+
code: "missing-tool-output",
|
|
73
|
+
message: "No tool output was returned for a required function call.",
|
|
74
|
+
guidance: "Retry the message. If this keeps happening, restart the relay and include the error code.",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (lower.includes("tool call") && (lower.includes("not found") || lower.includes("missing"))) {
|
|
78
|
+
return {
|
|
79
|
+
code: "tool-call-missing",
|
|
80
|
+
message: "A required tool call was missing in the runtime response.",
|
|
81
|
+
guidance: "Retry the message. If it repeats, restart the relay and include the error code.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (lower.includes("tool output") && lower.includes("invalid")) {
|
|
85
|
+
return {
|
|
86
|
+
code: "invalid-tool-output",
|
|
87
|
+
message: "Tool output returned from the runtime was invalid.",
|
|
88
|
+
guidance: "Retry the message. If it repeats, restart the relay and include the error code.",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Monthly/plan quota exhaustion is not transient: retrying before the
|
|
92
|
+
// billing window resets can never succeed. Distinct from "rate limit"
|
|
93
|
+
// (per-minute throttling), which stays retryable below.
|
|
94
|
+
if ((lower.includes("quota") && (lower.includes("exceeded") || lower.includes("exhaust")))
|
|
95
|
+
|| lower.includes("out of ai credits")
|
|
96
|
+
|| lower.includes("premium request allowance")) {
|
|
97
|
+
return QUOTA_EXHAUSTED;
|
|
98
|
+
}
|
|
99
|
+
if ((lower.includes("capierror") || lower.includes("http 400") || lower.includes("status 400") || /^400\b/.test(lower))
|
|
100
|
+
&& !lower.includes("timeout")
|
|
101
|
+
&& !lower.includes("temporar")
|
|
102
|
+
&& !lower.includes("rate limit")) {
|
|
103
|
+
return {
|
|
104
|
+
code: "request-invalid",
|
|
105
|
+
message: "The runtime rejected the request as invalid and non-retryable.",
|
|
106
|
+
guidance: "Retry after adjusting the request. If it persists, restart the relay and include the error code.",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `options` lets a caller that holds structured error fields (an SDK error
|
|
115
|
+
* payload rather than a thrown `Error`) pass them in explicitly:
|
|
116
|
+
* `{ quota: true }`, or `{ errorCode, errorType, statusCode }` to be judged
|
|
117
|
+
* here. Structured fields carried on the error object itself (`error.code`,
|
|
118
|
+
* `error.statusCode`) are read as well, so plain HTTP-ish errors classify
|
|
119
|
+
* without the caller doing anything.
|
|
120
|
+
*/
|
|
121
|
+
export function normalizeTerminalSendAndWaitError(error, options = {}) {
|
|
122
|
+
const detail = readErrorText(error);
|
|
123
|
+
const quota = options.quota === true
|
|
124
|
+
|| isStructuredQuotaError(options)
|
|
125
|
+
|| isStructuredQuotaError(error);
|
|
126
|
+
const base = classifyTerminalError(detail, { quota });
|
|
127
|
+
if (!base) return null;
|
|
128
|
+
const functionCallId = extractFirstMatch(detail, /function call\s+([a-z0-9_-]+)/i)
|
|
129
|
+
|| extractFirstMatch(detail, /\b(call_[a-z0-9_-]+)/i);
|
|
130
|
+
// GitHub request ids are colon-separated hex segments (AFAC:3C07CD:…).
|
|
131
|
+
const requestId = extractFirstMatch(detail, /\brequest(?:\s+id|_id)?[:=]\s*([a-z0-9_-]+(?::[a-z0-9_-]+)*)/i)
|
|
132
|
+
|| extractFirstMatch(detail, /\b(req_[a-z0-9_-]+)/i);
|
|
133
|
+
return {
|
|
134
|
+
terminal: true,
|
|
135
|
+
code: base.code,
|
|
136
|
+
stableCode: `relay.${base.code}`,
|
|
137
|
+
message: base.message,
|
|
138
|
+
guidance: base.guidance,
|
|
139
|
+
detail: detail || "unknown error",
|
|
140
|
+
functionCallId,
|
|
141
|
+
requestId,
|
|
142
|
+
classificationHint: toKebabToken(error?.code || error?.name || null),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function isTerminalSendAndWaitError(error, options = {}) {
|
|
147
|
+
return !!normalizeTerminalSendAndWaitError(error, options);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function buildTerminalFailureText(error, options = {}) {
|
|
151
|
+
const normalized = normalizeTerminalSendAndWaitError(error, options) || {
|
|
152
|
+
stableCode: "relay.unknown-terminal",
|
|
153
|
+
message: "The relay runtime hit a terminal error and could not complete this turn.",
|
|
154
|
+
guidance: "Retry the message.",
|
|
155
|
+
detail: readErrorText(error) || "unknown error",
|
|
156
|
+
functionCallId: null,
|
|
157
|
+
requestId: null,
|
|
158
|
+
};
|
|
159
|
+
const ids = [
|
|
160
|
+
normalized.functionCallId ? `functionCallId=${normalized.functionCallId}` : null,
|
|
161
|
+
normalized.requestId ? `requestId=${normalized.requestId}` : null,
|
|
162
|
+
].filter(Boolean);
|
|
163
|
+
return [
|
|
164
|
+
normalized.message,
|
|
165
|
+
`Error code: ${normalized.stableCode}.`,
|
|
166
|
+
ids.length ? `IDs: ${ids.join(", ")}.` : null,
|
|
167
|
+
normalized.guidance,
|
|
168
|
+
`Details: ${normalized.detail}`,
|
|
169
|
+
].filter(Boolean).join(" ");
|
|
170
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
function normalizeId(value) {
|
|
2
|
+
const text = String(value ?? "").trim();
|
|
3
|
+
return text || null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function extractTargetSessionId(details, conversationId = null) {
|
|
7
|
+
return normalizeId(
|
|
8
|
+
details?.sdkSessionId
|
|
9
|
+
|| details?.runtimeSession?.sdkSessionId
|
|
10
|
+
|| details?.runtimeSession?.sdk_session_id
|
|
11
|
+
|| "",
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveSessionBinding({
|
|
16
|
+
conversationId,
|
|
17
|
+
details,
|
|
18
|
+
activeSessionId,
|
|
19
|
+
} = {}) {
|
|
20
|
+
const convId = normalizeId(conversationId);
|
|
21
|
+
const targetSessionId = extractTargetSessionId(details, convId);
|
|
22
|
+
const active = normalizeId(activeSessionId);
|
|
23
|
+
|
|
24
|
+
if (!convId) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
reason: "conversation-id-missing",
|
|
28
|
+
retryable: false,
|
|
29
|
+
message: "Missing conversation id for session binding check",
|
|
30
|
+
activeSessionId: active,
|
|
31
|
+
targetSessionId: null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!targetSessionId) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
reason: "target-session-missing",
|
|
39
|
+
retryable: true,
|
|
40
|
+
message: "Conversation has no bound SDK session id yet",
|
|
41
|
+
activeSessionId: active,
|
|
42
|
+
targetSessionId: null,
|
|
43
|
+
canClaim: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!active) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
reason: "active-session-missing",
|
|
51
|
+
retryable: true,
|
|
52
|
+
message: "No active SDK runtime session available",
|
|
53
|
+
activeSessionId: null,
|
|
54
|
+
targetSessionId,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (targetSessionId !== active) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: "session-binding-mismatch",
|
|
62
|
+
retryable: true,
|
|
63
|
+
message: `Conversation is bound to SDK session ${targetSessionId}`,
|
|
64
|
+
activeSessionId: active,
|
|
65
|
+
targetSessionId,
|
|
66
|
+
canClaim: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
switched: false,
|
|
73
|
+
via: "session-liveness",
|
|
74
|
+
activeSessionId: active,
|
|
75
|
+
targetSessionId,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|