Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Go code uses a GOPATH layout: the repo root is `GOPATH`, packages live under `sr

- **REPL**: an interactive interpreter (Read-Eval-Print Loop), for example `cling` or `python`.
- **Master / slave (webtty)**: the *master* is the browser WebSocket; the *slave* is the PTY-backed process.
- **Master / slave (sharing)**: the *master* browser owns the real WebSocket session; a *slave* browser opened `…/#<dbpath>` and mirrors it through Firebase.
- **Master / slave (sharing)**: the *master* browser owns the real WebSocket session; a *slave* browser opened `…/#<dbpath>` (a Firebase push key; any other `#anchor` is just an anchor) and mirrors it through Firebase.
- **jid**: an encoded PID of a running REPL (`encoder.EncodePID`). Used by **Fork REPL** and by extra terminal tabs to join that REPL's namespaces.
- **optionchange / optionrun / optiondebug**: DOM events on a terminal element. They mean, respectively: switch language (start a fresh REPL), run the editor content, and run it in debug mode.
- **Demo**: an entry in `demos.xml` describing one REPL (animation, usage, docs, starter code, Compiler script).
Expand Down
8 changes: 7 additions & 1 deletion docs/lld/06-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ flowchart LR

## 4. Live sharing (`FireTTY`)

A page is the **master** when the URL has no `#hash`. It is a **slave** (viewer) when it was opened as `…/#<dbpath>`. `dbpath` is the page's Firebase push key (`getExampleRef()`), and the *Share REPL* box shows `origin + path + search + "#" + dbpath`.
A page is the **master** unless it was opened with a share link. It is a **slave** (viewer) when it was opened as `…/#<dbpath>`. `dbpath` is the page's Firebase push key (`getExampleRef()`), and the *Share REPL* box shows `origin + path + search + "#" + dbpath`. Only a hash that has the shape of a push key counts (`js/src/share-id.ts`: `-` then 12 to 30 of `A-Za-z0-9_-`; the SDK's keys are 20 long and start with `-` until 2109). Any other hash is an anchor of the page (`#languages`, `#workspace`, `#request`), and the page stays a master: before this rule every hash made a viewer, so using the home page's own links broke the terminal ("master terminal is not available"). The id is read once, when each script loads (the page script, the terminal client and the Genie widget each have it), so clicking an anchor later changes neither a master nor a viewer; a different share link pasted into the address bar of an open page loads the page again. The widget keeps its own copy of the pattern, because it is built apart; `test/share-id.test.ts` compares the two. Tests: `src/js/test/share-id.test.mjs` (the rule, and the page script run with a pretend page).

```mermaid
sequenceDiagram
Expand Down Expand Up @@ -168,6 +168,12 @@ It uses no jQuery, DataTables, select2 or Font Awesome.

**Store** (`js/practice-store.js`, `window.PracticeStore`), used by the list and the editor: `all()`, `find(id or slug)`, `isDone`, `setDone`, `add` (what `saveNewQuestions` now calls), `update` (sets `updated`), `remove` (leaves a tombstone), `init()` (starts account sync), `status()`, `onChange(fn)`. Storage keys and sync: LLD 05. Another tab's changes arrive through the `storage` event.

**Starter questions** (`js/dsa.json`, `seed()` in `practice-store.js`). The file lists about 300 DSA problems as `{title, topic, difficulty}`. When either practice page loads, `seed()` fetches it and adds to the visitor's `questions` list every problem that is not there yet: id `s-<slug>`, `starter: true`, a readable name ("Best Time to Buy and Sell Stock II"), no description or code, and `added` counting down from 1 Jan 2025 so that they sort after anything generated, in the file's order. They are ordinary entries, so the pages that read `localStorage.questions` directly (the editor, `getCodeTemplate`) find them; a description and code are generated when one is first opened, as for any question. Starters are not counted in the 100 generated questions a visitor keeps (`trimOwn`), and one nobody has opened is not sent to the account (`untouched`); its done mark and a deletion are, in `practiceState`, which is also what keeps a deleted starter from coming back. A problem added to the file later reaches existing visitors on their next visit. The editor waits for `PracticeStore.ready()` before it looks for its question, so a link to a starter works on a first visit. Tests: `src/js/test/practice-seed.test.mjs`.

**Pages of the list** (`scribbler-misc.js`: `render`, `renderPager`, `pageNumbers`; `#q-pager` in `practice.html`). The list shows one page of rows at a time: 10, 20 (the default), 40 or 100 (`#f-size`, kept with the filters in `practiceFilters`). Under the table are the size, the range ("21–40 of 301", or "301 of 301") and Prev, page numbers and Next, with the first and last page always shown, a window around the current one and `…` for the gaps; the current page has `aria-current="page"`. A search, a filter, the sort or the size takes the list back to page 1; a tick or a delete keeps the page (and moves back to the last one if a delete left fewer). After a page button is pressed the keyboard stays on Prev, Next or the current page, which are drawn again, and the top of the list scrolls into view. With 10 rows or fewer the pager is hidden. On a phone the page buttons wrap under the size.

**Opening a question** (`js/question-loader.js`, `fetchLanguageTmplAndSetEditorcode` in `index.html`, `getCodeTemplate` in `common.js`). A question on `/practice?name=<slug>` that has no description or no starter for the chosen language is written by Genie when it is first opened. A card (`#question-loading`, over `.editor-body`) says so from the moment the page loads, not only when the fetch starts: "Getting your question ready…", then "Genie (GPT-6 Luna) is writing “Two Sum” and a starter for Python. This usually takes 5 to 20 seconds." (the model chosen in Genie's model menu), with "Still working. A busy model can take up to a minute." after 20 seconds and a Cancel button throughout (Esc cancels too). The request is dropped after **60 seconds** (`QuestionLoader.settings.timeoutMs`; the proxy's own limit to the model is 90 seconds, `openAITimeout`, and a visitor should not wait for that). Whatever goes wrong, the card turns into an error card (`role=alert`) that says why, with Try again and Close: `getCodeTemplate` and `apiFailure` throw an Error with `.kind` (`timeout`, `offline` when the request could not be made, `limit` for 429, `auth` for 401 and 403, `off` when Genie or the model is switched off (503 `GenieDisabled`, `model_unavailable`; the proxy's own words are shown), `empty` when the model answered with nothing in it (the proxy's 502 `model_empty` for an OpenRouter model, or a reply without content seen by the page; the card says which model and why as far as the reply told, for example that it used its whole answer budget thinking), `format` for an answer that cannot be used, otherwise `server` with the HTTP status), and `describeFailure` turns each into a title, a sentence and, where there is one, the status. Cancel is not an error: the card goes away. Close, and a cancel, leave the editor with the question's header (name, level, description) without the starter code; a description that is not there says so instead of "null". A newer load (another language picked meanwhile) stops the older one without showing an error. A question that is already complete (description and a starter for the language) shows no card. Before this, a failure only wrote to the console, there was no time limit, nothing showed during the 4-second wait, and the description Genie wrote was never saved, so a starter question opened as "Problem Description: null". Tests: `src/js/test/question-loader.test.mjs`.

**New question dialog** (`#modal`, same markup on both pages). A topic text field with suggestions from `topics` (a `<datalist>`, so any topic can be typed), level, creativity (the LLM temperature) and extra instructions. `practice-store.js` moves focus into it, keeps Tab inside, closes it on Esc and returns focus, and shows "Generating…" on the button while `common.js` works.

**In the editor** (`/practice?<Lang>&name=<slug>`), `index.html` adds:
Expand Down
2 changes: 2 additions & 0 deletions docs/lld/07-ai-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ Genie can work on a task in steps in the editor. Full design and decisions: `doc
- **A closed panel** (`index.ts`, `renderActivity`): closing the panel hides it and stops nothing. A chat request or a task goes on (the message list and the task card are kept outside the panel's markup, which is built again on each open), and the two Ask Genie buttons (`[data-chat-widget-button]`, `#genie-button`) get `data-genie-state`: `working` (the icon turns; the floating one reads "Genie is working… 3/8" or "Genie is thinking…"), `needs` (amber dot: a permission question or a diff is open, and the task waits for it), `done` or `failed` (green or red dot: a result came while the panel was closed, cleared when it is opened). While the panel is closed the tab title carries the same words ("(1) Task done · …") and a hidden `role=status` element says them to a screen reader. The state is worked out each time from four facts (`needsYou`, `agentRunner.running`, `chatInFlight`, `unread`), which the runner reports through three optional host calls: `attention(on)`, `progress(n, max)` and `ended("done" | "failed" | "stopped")`. On open, the send button is set from the same facts. Only Stop, a reload or leaving the page ends a task; the server's limits (steps, 15 minutes, tasks an hour) are unchanged.
- **The REPL guide** (`server/repl_guide.go`): every step names the REPL of the language in use (`repl`, the name the page asks `/demo?q=` for), and `agentBody` puts a second system message after the protocol: the REPL's prompt, its commands, an example session and, where there are any, notes on its habits. All of it comes from the REPL's entry in `resources/meta/demos.xml` (`Codes`, `Usage`, and `Notes`, which is for Genie only and is not in the `/demo` answer), so a REPL added there has a guide, and the text is never the page's: a name without a demo adds nothing, and `repl` is not sent on. gointerpreter has notes (a block runs only with `:r`, `:r` runs the session and not the editor, `:c` after an error), written after a model was seen restarting the terminal and typing `:r` where a line of Go was wanted. A guide is at most 3,600 characters. Chat mode does not get it; the note `knowledge/go-repl.md` answers a user's question there.
- **What is left** (`server/usage.go`, `index.ts` `renderUsage`): `GET /chat/usage` and the `X-OpenREPL-Usage` header of every answer of `/chat/completions` carry the same JSON: `left`, `cap`, `perMinute`, `unlimited` (an admin), `signedIn`, and for a signed-in user where agent mode is on `agent: {used, perHour, nextFreeSeconds, maxSteps}`. Asking saves no cookie and charges nothing (`cookie.OpenApiRequestBalance` works the recharge out without saving it; `agentLedger.started` counts the hour's tasks). The panel asks when it opens and reads the header after each answer and each agent step; between them it counts the recharge itself, once a second. It shows one line under the composer ("52 of 60 requests left"; in Agent mode "17 of 20 tasks left this hour"; amber when few are left; "No requests left · next in 40 s" in red at zero; "No limit (admin)"), with the details behind a click, and it is hidden in peer chat. At zero the send button is dimmed and a submit is not sent (the line flashes and the text stays in the box), so the visitor does not meet the proxy's 429. The count shown is the balance rounded up, because the proxy takes a request while the balance is above 0.
- **Peer chat** (`index.ts`: `peerName`, `peerNote`, `PeerSpeaker`, `setupFBListener`). With the Peer chat switch on, the people in a shared session talk to each other through Firebase `chat-list/<share id>` instead of asking Genie. Each person is `user-<5 hex>` (`peerId()`, kept in `localStorage` `genie-peer-id`, so the name is the same in every tab and visit of a browser; the same id prefixes their questions to Genie), and each tab also has its own `TAB` id so that two tabs of one browser still show each other's messages (a message is skipped only when `tab` is this tab's; an older page that sends no `tab` is skipped by `uid`). What is on screen depends on who looks: your own messages are the light bubble on the right, labelled "You · user-xxxxx", everybody else's are on the left under their name. A message typed in a peer chat is stored with `peer: true` and `name`; the rest of the mirrored conversation (the owner's Genie questions and its answers) has no `peer` flag and is shown as it always was. Small notes (`.chat-widget__peer-note`) say "user-xxxxx joined the chat", "... left the chat", "... turned peer chat on" (and, to the person who turned it on, "Peer chat is on. You are user-xxxxx."). A viewer who opens a share link writes a `join` event once the listener is up (and tells itself "You joined the chat as ..."), and a `leave` event on `pagehide`/`unload` (best effort: the page may be gone before it is sent; a page restored from the back/forward cache joins again). The owner writes no join event, so a page nobody shares leaves nothing in the database, and says who it is with its first message; the owner's `unload` still removes the whole `chat-list/<id>` entry. Late joiners get the history replayed, notes included.
- **A reply with no answer in it** (`chatproxy.go`: `emptyAnswer`, `modelAnswerEmpty`). OpenRouter can answer 200 and still have nothing to show: an `error` object in the body or in the first choice, no choices, or a message whose content is empty (a free model that is busy, or one that spent its whole budget thinking: `finish_reason: "length"`). For a whole (not streamed) OpenRouter reply the proxy reads the body (up to 4 MB) and, when it finds that, answers 502 with type `model_unavailable` and code `model_empty`: "<Model> sent back an empty answer: it used its whole answer budget before writing anything …" / "it wrote nothing" / "it stopped without writing anything (<finish_reason>)" / "its host reported an error". The host's own words stay in the log (they can name the account), the request is not charged, and an agent step is given back. A real answer, a tool call, content in parts and anything that is not JSON are passed on byte for byte. The Genie panel shows it as it shows an unavailable model (retry, or another model); the practice editor's card has its own text for it (kind `empty`, LLD 06). A model that keeps answering `length` needs more *room for thinking* in the dashboard (OpenRouter models card).
- **Admin:** the Genie settings (`agentDisabled`, `agentOnPractice`, `agentTasksPerHour`, `agentMaxSteps`) are in the Agent mode card of `/admin` (LLD 13) and in `settings.js` (`agentEnabled`, true unless an admin switched it off and only while Genie itself is on, `agentOnPractice`, `agentMaxSteps`).
- **Tests:** `agent_test.go` (the ledger: steps, tokens, expiry, the hourly count, giving a step back; the proxy: guest, switched off, practice, charging only the first step, the model failing, admins, the settings and the audit log; and a test that compares the action list with `agent-protocol.ts`); `test/agent-protocol.test.ts` in the chat widget (`npm test` there; Node 22.18 or later), for what the page accepts from the model.

Expand Down
9 changes: 5 additions & 4 deletions src/js/src/firetty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// instead of bundling a second copy (Firebase 3.9) into gotty-bundle.js (T16).
declare var firebase: any;
import { Terminal, eventHandler, eventhandlertype, CloserArgs} from "./webtty";
import { shareIdFrom } from "./share-id";


var dbpath = "";
Expand All @@ -26,10 +27,10 @@ export const getExampleRef = () => {
return window['dbpath'];
}
var ref = getrepl_firebasedbref();
var hash = window.location.hash.replace(/#/g, '');
if (hash) {
window['dbpath'] = hash;
return hash;
var shared = shareIdFrom(window.location.hash);
if (shared) {
window['dbpath'] = shared;
return shared;
}
ref = ref.push();
if (ref.key) {
Expand Down
11 changes: 5 additions & 6 deletions src/js/src/gotty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Xterm } from "./xterm";
import { Terminal, WebTTY, protocols, jidHandler, Icallback, WebTTYFactory, IdeLangKey, IdeContentKey, IdeFileNameKey, CompilerOptionKey, CompilerFlagsKey, EnvFlagsKey, CloserArgs} from "./webtty";
import { ConnectionFactory } from "./websocket";
import { FireTTY, DisableShareBtn } from "./firetty";
import { shareIdFrom } from "./share-id";

const option2args = {
"c":"arg=-xc&arg=-noruntime",
Expand All @@ -19,13 +20,11 @@ export interface CustomHTMLElement extends HTMLElement {
isprimary: boolean; // if this corressponds to primary terminal tab element
}

// the share id this page was opened with, read once (share-id.ts): an anchor clicked later is not a new session
const shareId = shareIdFrom(window.location.hash);

function isMaster() : boolean {
var hash = window.location.hash.replace(/#/g, '');
if (!hash) {
return true;
} else {
return false;
}
return shareId === "";
}

function handleTerminalOptions(elem, option, event="optionchange") {
Expand Down
26 changes: 16 additions & 10 deletions src/js/src/page/01-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// into js/scribbler.js. Top-level names are page globals (window.*), as they
// were in the single file: index.html, palette.js and the other parts use them.

import { shareIdFrom } from "../share-id";

window.handleClickOutside = handleClickOutside;
window.ismob = ismob;
window.isMaster = isMaster;
Expand All @@ -13,15 +15,24 @@ window.preprocessurl = preprocessurl;

window.homedir = ""; // home directory

// The share id of this page, if it was opened as /#<id> (src/share-id.ts). It is
// read once: an anchor such as #languages that is clicked later changes the
// hash, not the session. A different share link pasted into the address bar of
// an open page is a different session, so the page loads again.
const SHARE_ID = shareIdFrom(window.location.hash);
window.addEventListener("hashchange", function () {
const now = shareIdFrom(window.location.hash);
if (now && now !== SHARE_ID) window.location.reload();
});

window.getExampleRef = () => {
if(window.dbpath) {
return window.dbpath;
}
var ref = firebase.database().ref("openrepl");
var hash = window.location.hash.replace(/#/g, '');
if (hash) {
window.dbpath = hash;
return hash;
if (SHARE_ID) {
window.dbpath = SHARE_ID;
return SHARE_ID;
}
ref = ref.push();
if (ref.key) {
Expand Down Expand Up @@ -71,12 +82,7 @@ function ismob() {
}

function isMaster() {
var hash = window.location.hash.replace(/#/g, '');
if (!hash) {
return true;
} else {
return false;
}
return SHARE_ID === ""; // a viewer was given a share link; an anchor is not one
}

window.option2cmdMap = {
Expand Down
24 changes: 24 additions & 0 deletions src/js/src/share-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// What in the address of a page is a share id.
//
// A shared session is opened as https://openrepl.com/#<id>, where <id> is the
// Firebase push key of the owner's page: 20 characters, the first of them "-"
// (8 of time and 12 random from the alphabet - 0-9 A-Z _ a-z; the SDK checks
// the length, and the first one stays "-" until the year 2109). The accepted
// length is a little wider than 20, so that a change of the SDK's key could not
// stop sharing.
//
// Anything else after the # is only an anchor of the page: #languages,
// #workspace, #request. It must not turn the visitor into a viewer of a session
// that does not exist, which is what "any hash is a share id" did.
//
// The same rule is in chat-widget/src/index.ts (its own bundle); a test there
// compares the two patterns. Callers read the id once, when their script loads,
// and keep it: clicking an anchor later changes the hash, not the session.

export const SHARE_ID_PATTERN = /^-[A-Za-z0-9_-]{12,30}$/;

// The share id in a location.hash ("#-OJVdrF-2UPpWUafqBoU"), or "" if there is none.
export function shareIdFrom(hash: string): string {
const id = String(hash || "").replace(/^#/, "");
return SHARE_ID_PATTERN.test(id) ? id : "";
}
Loading
Loading