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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/readm3",
"version": "0.24.4",
"version": "0.25.0",
"description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.",
"type": "module",
"license": "MIT",
Expand Down
6 changes: 6 additions & 0 deletions server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const json = (
) => Response.json(body, { status, headers: { ...headers, ...extra } });
/** A file sent for a summary: up to 20 MB as base64, with room for the JSON around it. */
const MAX_SUMMARY_BODY = 28 * 1024 * 1024;
/** Ask AI: 400 KB of file text plus paths, the question and the turns before it, JSON-escaped. */
const MAX_ASK_BODY = 2 * 1024 * 1024;
async function body(request: Request, max = MAX_BODY): Promise<Record<string, unknown>> {
if (!request.headers.get("content-type")?.startsWith("application/json"))
throw new HttpError(415, "Use application/json.");
Expand Down Expand Up @@ -350,6 +352,10 @@ export function createApi(
limit(`summary:${user.id}`, 30);
return json(await money.billing.summary(user, await body(request)));
}
if (money && path === "ask" && request.method === "POST") {
limit(`ask:${user.id}`, 30);
return json(await money.billing.ask(user, await body(request, MAX_ASK_BODY)));
}
if (money && path === "summaries/document" && request.method === "POST") {
limit(`summary:${user.id}`, 30);
return json(await money.billing.documentSummary(user, await body(request, MAX_SUMMARY_BODY)));
Expand Down
87 changes: 87 additions & 0 deletions server/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export const DEFAULT_MODEL = "claude-opus-5-5";
export const DEFAULT_OPENAI_MODEL = "gpt-5.2";
/** Whole-document summaries each account gets free per week (Monday 00:00 UTC to the next). */
export const FREE_DOCUMENTS_PER_WEEK = 10;
/** Ask AI questions each account gets free per UTC day. */
export const FREE_QUESTIONS_PER_DAY = 10;
/** Text of the files one question may carry, and the earlier turns it may repeat. */
export const MAX_ASK_CHARS = 400 * 1024;
export const MAX_ASK_FILES = 500;
export const MAX_QUESTION_CHARS = 4_000;
export const MAX_ASK_TURNS = 10;
/** Longest text sent per side, and the most of the diff the model is shown. */
export const MAX_SOURCE = 400 * 1024;
/** The largest PDF or office file a summary reads, and the largest image (the models' own per-image cap). */
Expand Down Expand Up @@ -178,6 +185,19 @@ export const DOCUMENT_SYSTEM = `You summarize a document for the person reading
Write a two or three sentence abstract of what the document is and says, then up to five short bullet points with its key points, decisions, numbers or steps.
Be concrete and stay with what the document says; do not add advice of your own. Plain text, no heading, no preamble.`;

export const ASK_SYSTEM = `You answer questions about the person's own files, which are given to you below with their paths.
Answer from what the files say; quote or name the file (by path) a fact comes from. If the files do not answer the question, say so plainly instead of guessing.
Be direct and concise. Plain text; short lists are fine.`;

/** The prompt for one question: every file under its path, then the conversation so far, then the question. */
export function askPrompt(files: { path: string; text: string }[], history: { question: string; answer: string }[], question: string, skipped = 0): string {
const parts = files.map((file) => `=== ${file.path} ===\n${file.text}\n`);
if (skipped) parts.push(`(${skipped.toLocaleString("en-US")} more file(s) were left out: too large, or not text.)\n`);
for (const turn of history) parts.push(`Earlier question: ${turn.question}\nYour answer: ${turn.answer}\n`);
parts.push(`Question: ${question}`);
return parts.join("\n");
}

/**
* The prompt for a whole document: its name, and its text cut to the budget with a note
* saying so. `note` says where the text came from when it was read out of a file.
Expand Down Expand Up @@ -227,6 +247,72 @@ export class Billing {
store.db.exec(BILLING_SCHEMA);
}

/** Ask AI questions free per UTC day (READM3_FREE_QUESTIONS_PER_DAY). */
freeQuestionsPerDay = FREE_QUESTIONS_PER_DAY;

/** Today's free Ask AI questions: how many are left and when the count starts again (00:00 UTC). */
freeQuestions(userId: string, at = new Date()) {
const start = new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()));
const used = this.store.get<{ n: number }>(
"SELECT COUNT(*) AS n FROM billing_ledger WHERE userId=? AND ref LIKE 'free-question:%' AND createdAt>=?", userId, start.toISOString(),
)?.n ?? 0;
const resets = new Date(start);
resets.setUTCDate(resets.getUTCDate() + 1);
return { perDay: this.freeQuestionsPerDay, left: Math.max(0, this.freeQuestionsPerDay - used), resetsAt: resets.toISOString() };
}

/**
* Ask AI: a question about the person's files (text they send: one file, a folder, or
* the workspace), with the conversation so far. Free while today's allowance lasts,
* then from the balance at the arm's price; admins are never charged.
*/
async ask(user: User, args: Record<string, unknown>) {
if (!this.summarize) throw new HttpError(503, "Ask AI is not configured on this server.");
const question = typeof args.question === "string" ? args.question.trim() : "";
if (!question) throw new HttpError(400, "Ask a question.");
if (question.length > MAX_QUESTION_CHARS) throw new HttpError(413, `A question may be up to ${MAX_QUESTION_CHARS.toLocaleString("en-US")} characters.`);
if (!Array.isArray(args.files) || !args.files.length) throw new HttpError(400, "files must list the files to ask about.");
if (args.files.length > MAX_ASK_FILES) throw new HttpError(413, `Ask about up to ${MAX_ASK_FILES} files at once.`);
let total = 0;
const files = args.files.map((file) => {
const { path, text } = (file ?? {}) as Record<string, unknown>;
if (typeof path !== "string" || !path.trim() || typeof text !== "string") throw new HttpError(400, "Each file needs a path and its text.");
total += text.length;
return { path: path.trim().slice(0, 1024), text };
});
if (total > MAX_ASK_CHARS) throw new HttpError(413, "The files may hold up to 400 KB of text for one question; ask about a smaller folder.");
const history = (Array.isArray(args.history) ? args.history : []).slice(-MAX_ASK_TURNS).map((turn) => {
const { question: q, answer: a } = (turn ?? {}) as Record<string, unknown>;
return { question: typeof q === "string" ? q.slice(0, MAX_QUESTION_CHARS) : "", answer: typeof a === "string" ? a.slice(0, 16_000) : "" };
}).filter((turn) => turn.question && turn.answer);
const skipped = Number.isSafeInteger(args.skipped) && Number(args.skipped) > 0 ? Number(args.skipped) : 0;
const text = askPrompt(files, history, question, skipped);

const arm = this.arm(user);
const exempt = this.exempt(user.id);
const free = !exempt && this.freeQuestions(user.id).left > 0;
const estimate = this.estimate(arm, text, ASK_SYSTEM);
if (!exempt && !free && this.balance(user.id) < estimate) {
throw new HttpError(402, `Today's ${this.freeQuestionsPerDay} free questions are used. Not enough balance for another (about $${(estimate / 1e6).toFixed(4)}). Top up to continue, or come back tomorrow.`);
}
if (this.running.has(user.id)) throw new HttpError(429, "An answer is already being written for this account.");
this.running.add(user.id);
let result: Awaited<ReturnType<Summarize>>;
try { result = await this.summarize(ASK_SYSTEM, text); }
catch { throw new HttpError(502, "The answer could not be written. You were not charged; try again."); }
finally { this.running.delete(user.id); }
if (result.refused || !result.text) throw new HttpError(422, "The model declined to answer this. You were not charged.");
const model = result.model ?? this.model;
const cost = costMicros(model, result.usage);
const charged = free || exempt ? 0 : priceMicros(cost, arm);
this.store.run(
"INSERT INTO billing_ledger (id,userId,kind,amountMicros,ref,arm,detail,createdAt) VALUES (?,?,?,?,?,?,?,?)",
id(), user.id, "charge", -charged, `${free ? "free-question" : "question"}:${id()}`, free || exempt ? null : arm,
JSON.stringify({ kind: "question", files: files.length, model, costMicros: cost, input: result.usage.input_tokens, output: result.usage.output_tokens }), now(),
);
return { answer: result.text, model, chargedMicros: charged, free, exempt, freeQuestions: this.freeQuestions(user.id), balanceMicros: this.balance(user.id) };
}

/** This week's free document summaries: how many are left and when the count starts again. */
free(userId: string, at = new Date()) {
const start = weekStart(at);
Expand Down Expand Up @@ -285,6 +371,7 @@ export class Billing {
enabled: !!this.summarize,
listen: !!this.speak,
freeSummaries: this.free(user.id),
freeQuestions: this.freeQuestions(user.id),
ledger: this.store.all<{ kind: string; amountMicros: number; detail: string; createdAt: string }>(
"SELECT kind,amountMicros,detail,createdAt FROM billing_ledger WHERE userId=? ORDER BY createdAt DESC LIMIT 20", user.id,
),
Expand Down
26 changes: 26 additions & 0 deletions server/test/billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,32 @@ describe("top-ups", () => {
});
});

describe("ask", () => {
test("ten free questions a day, then the balance; the files and the earlier turns reach the model", async () => {
const prompts: string[] = [];
const f = await fixture(async (_system, text) => { prompts.push(text); return { text: "It is 443.", usage: { input_tokens: 1000, output_tokens: 100 }, refused: false }; });
f.billing.freeQuestionsPerDay = 2;
const args = { question: "Which port?", files: [{ path: "app/config.json", text: '{"port":443}' }], history: [{ question: "What is this?", answer: "A config." }] };
for (let i = 0; i < 2; i++) {
const done = await f.call("ask", "POST", args, f.auth);
expect(done.status).toBe(200);
expect(done.data).toMatchObject({ answer: "It is 443.", free: true, chargedMicros: 0 });
}
expect(prompts[0]).toContain("=== app/config.json ===");
expect(prompts[0]).toContain("Earlier question: What is this?");
expect(prompts[0]).toContain("Question: Which port?");
const refused = await f.call("ask", "POST", args, f.auth);
expect(refused.status).toBe(402);
expect(refused.data.error).toContain("free questions are used");
f.billing.credit(f.user.id, 1_000_000, "test:1", "test");
const paid = await f.call("ask", "POST", args, f.auth);
expect(paid.data.free).toBe(false);
expect(paid.data.chargedMicros).toBeGreaterThan(0);
expect((await f.call("ask", "POST", { ...args, files: [] }, f.auth)).status).toBe(400);
expect((await f.call("ask", "POST", { ...args, files: [{ path: "big.txt", text: "x".repeat(401 * 1024) }] }, f.auth)).status).toBe(413);
});
});

describe("admins", () => {
test("an admin is never charged and never refused for an empty balance", async () => {
const f = await fixture();
Expand Down
138 changes: 138 additions & 0 deletions site/ask-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { ApiError, request } from "./cloud.ts";
import { dollars, el } from "./history-panel.ts";
import { binaryType } from "../src/code.ts";
import type { Document } from "./workspace.ts";

/**
* Ask AI (readm3.com/talk): a conversation with the model about your own files, the open
* one, a folder it is in, or the whole workspace. Only text goes (a PDF, an image or a
* file stored apart is left out and counted), up to 400 KB of it per question. Signed in,
* the first 10 questions each day are free (server/billing.ts FREE_QUESTIONS_PER_DAY),
* then each is paid from the prepaid balance; admins are never charged.
*/
interface FreeQuestions { perDay: number; left: number; resetsAt: string; }
interface Answered { answer: string; chargedMicros: number; free: boolean; exempt?: boolean; freeQuestions: FreeQuestions; balanceMicros: number; }
type Turn = { question: string; answer: string };

const MAX_TEXT = 400 * 1024;
const MAX_FILES = 500;

/** The folders holding `path`, nearest first: "a/b/c.md" is in "a/b", then "a". */
function folders(path: string): string[] {
const parts = path.split("/").slice(0, -1);
return parts.map((_, i) => parts.slice(0, parts.length - i).join("/"));
}

/** The text files under a scope ("" = everything), as much as one question carries. */
export function gather(documents: Document[], scope: { file?: string; folder?: string }) {
const chosen = documents.filter((doc) => scope.file !== undefined ? doc.path === scope.file
: !scope.folder || doc.path.startsWith(`${scope.folder}/`));
const files: { path: string; text: string }[] = [];
let size = 0;
let skipped = 0;
for (const doc of chosen) {
if (doc.blob || binaryType(doc.path) || !doc.source.trim() || files.length >= MAX_FILES || size + doc.source.length > MAX_TEXT) { skipped++; continue; }
files.push({ path: doc.path, text: doc.source });
size += doc.source.length;
}
return { files, skipped };
}

const freeLine = (free: FreeQuestions) => free.left > 0
? `${free.left} of ${free.perDay} free questions left today`
: `Today's ${free.perDay} free questions are used; more at midnight UTC`;

export async function showAsk(documents: () => Document[], active: string, topUp: () => void): Promise<void> {
const box = document.createElement("dialog");
box.className = "history-dialog summary-dialog ask-dialog";
box.setAttribute("aria-labelledby", "ask-title");
box.innerHTML = `<div class="dialog-header"><h2 id="ask-title">Ask AI about your files</h2><button type="button" aria-label="Close Ask AI">×</button></div>
<label class="ask-scope">Ask about <select id="ask-scope"></select></label>
<div class="summary-body ask-log" aria-live="polite"></div>
<form class="ask-form"><textarea id="ask-question" rows="3" placeholder="What does this say about…? Where is… set? Summarize the decisions in this folder." aria-label="Your question"></textarea><button type="submit" class="primary">Ask</button></form>
<p class="history-account"></p>`;
const log = box.querySelector<HTMLElement>(".ask-log")!;
const account = box.querySelector<HTMLElement>(".history-account")!;
const select = box.querySelector<HTMLSelectElement>("#ask-scope")!;
const form = box.querySelector<HTMLFormElement>(".ask-form")!;
const input = box.querySelector<HTMLTextAreaElement>("#ask-question")!;
const submit = form.querySelector<HTMLButtonElement>("button")!;
box.querySelector<HTMLButtonElement>(".dialog-header button")!.onclick = () => box.close();
box.addEventListener("click", (event) => { if (event.target === box) box.close(); });
box.addEventListener("close", () => box.remove());
document.body.append(box);
box.showModal();

const scopes: { label: string; scope: { file?: string; folder?: string } }[] = [
{ label: `This file · ${active.split("/").at(-1)}`, scope: { file: active } },
...folders(active).map((folder) => ({ label: `Folder · ${folder}/`, scope: { folder } })),
{ label: "Whole workspace", scope: {} },
];
for (const [i, { label }] of scopes.entries()) select.append(new Option(label, String(i)));
// Each scope keeps its own conversation; changing scope starts a new one.
const turns = new Map<number, Turn[]>();
const say = (message: string) => log.replaceChildren(el("p", "history-empty", message));
const describe = () => {
const { files, skipped } = gather(documents(), scopes[Number(select.value)]!.scope);
const past = turns.get(Number(select.value)) ?? [];
log.replaceChildren();
for (const turn of past) log.append(el("p", "ask-question", turn.question), el("p", "summary-text", turn.answer));
if (!past.length) say(files.length
? `${files.length.toLocaleString()} file${files.length === 1 ? "" : "s"} will be read${skipped ? ` (${skipped.toLocaleString()} left out: not text, too large, or past 400 KB)` : ""}. Ask anything about ${files.length === 1 ? "it" : "them"}.`
: "There is no text to ask about here. Pick a folder or the whole workspace.");
};
select.onchange = () => { describe(); input.focus(); };
input.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); form.requestSubmit(); } });

say("Loading…");
let billing: { enabled?: boolean; freeQuestions?: FreeQuestions; exempt?: boolean; balanceMicros: number } | null = null;
try { billing = await request("billing"); } catch { billing = null; }
if (!billing) {
const link = el("a", undefined, "Sign in (free)");
link.href = `/account?next=${encodeURIComponent("/talk")}`;
const line = el("p");
line.append(link, " to ask AI about your files: 10 questions a day free, then pay as you go.");
log.replaceChildren(line);
form.hidden = true;
return;
}
if (!billing.enabled) { say("Ask AI is not available on this server yet."); form.hidden = true; return; }
account.textContent = billing.exempt ? "Admin, no charges" : billing.freeQuestions ? freeLine(billing.freeQuestions) : "";
describe();
input.focus();

form.onsubmit = async (event) => {
event.preventDefault();
const question = input.value.trim();
if (!question || submit.disabled) return;
const index = Number(select.value);
const { files, skipped } = gather(documents(), scopes[index]!.scope);
if (!files.length) { say("There is no text to ask about here. Pick a folder or the whole workspace."); return; }
const past = turns.get(index) ?? [];
if (!past.length) log.replaceChildren();
const pending = el("p", "history-empty", "Thinking…");
log.append(el("p", "ask-question", question), pending);
input.value = "";
submit.disabled = true;
try {
const result = await request<Answered>("ask", "POST", { question, files, skipped, history: past });
pending.replaceWith(el("p", "summary-text", result.answer));
turns.set(index, [...past, { question, answer: result.answer }]);
const cost = result.exempt ? "Admin, no charge" : result.free ? "Free" : dollars(result.chargedMicros);
account.textContent = result.exempt ? cost : `${cost} · ${freeLine(result.freeQuestions)} · balance ${dollars(result.balanceMicros)}`;
} catch (error) {
pending.textContent = error instanceof Error ? error.message : "The question failed.";
input.value = question;
if (error instanceof ApiError && error.status === 402) {
const top = el("button", "ghost", "Top up");
top.type = "button";
top.onclick = () => { box.close(); topUp(); };
account.replaceChildren(top);
}
} finally {
submit.disabled = false;
log.scrollTop = log.scrollHeight;
input.focus();
}
};
}
7 changes: 7 additions & 0 deletions site/assets/viewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,13 @@ html, body.reader-app { height: 100%; overflow: hidden; }
.reader-app .summary-dialog { width: min(640px, calc(100vw - 32px)); }
.reader-app .summary-dialog .summary-body, .reader-app .summary-dialog > p { padding: 0 18px; }
.reader-app .summary-text { white-space: pre-wrap; line-height: 1.55; margin: 4px 0 8px; overflow-wrap: anywhere; }
.reader-app .ask-dialog .ask-log { max-height: min(55vh, 520px); overflow-y: auto; }
.reader-app .ask-dialog .ask-scope { display: flex; gap: 8px; align-items: center; padding: 0 18px 8px; min-width: 0; }
.reader-app .ask-dialog .ask-scope select { min-width: 0; flex: 1; }
.reader-app .ask-question { margin: 12px 0 2px; font-weight: 600; white-space: pre-wrap; overflow-wrap: anywhere; }
.reader-app .ask-form { display: flex; gap: 8px; align-items: flex-end; padding: 8px 18px; }
.reader-app .ask-form textarea { flex: 1; min-width: 0; resize: vertical; font: inherit; }
.reader-app .ask-form[hidden] { display: none; }
.reader-app .summary-listen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 8px; }
.reader-app .summary-listen audio { width: 100%; max-width: 420px; height: 36px; }
.reader-app .summary-listen small { color: var(--muted); }
Expand Down
Loading
Loading