Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f7b3eeb6e | ||
|
|
4f8eda2483 | ||
|
|
b539fff1b2 | ||
|
|
0d893d7fc5 | ||
|
|
5c8254cc50 | ||
|
|
b53016007a | ||
|
|
449f89fc38 |
No files matched your search
@@ -169,7 +169,8 @@ export function attach(chart, { data, schema, endpoint, dataset }) {
|
|||||||
|
|
||||||
const Base = globalThis.HTMLElement ?? class {};
|
const Base = globalThis.HTMLElement ?? class {};
|
||||||
|
|
||||||
// <ai-chart endpoint="https://..." dataset="orders" brain="jev"></ai-chart> brain: llm | jev | clef (server default if unset)
|
// <ai-chart endpoint="https://..." dataset="orders" brain="fast"></ai-chart>
|
||||||
|
// brain: fast (decision model first, LLM checks) | llm | jev | clef (server default if unset)
|
||||||
// Needs Chart.js on the page (window.Chart). Fires "spec" after each answer and "save" when Save is pressed; set
|
// Needs Chart.js on the page (window.Chart). Fires "spec" after each answer and "save" when Save is pressed; set
|
||||||
// element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1).
|
// element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1).
|
||||||
export class AiChart extends Base {
|
export class AiChart extends Base {
|
||||||
@@ -202,7 +203,9 @@ export class AiChart extends Base {
|
|||||||
try {
|
try {
|
||||||
await this.load();
|
await this.load();
|
||||||
const brain = this.getAttribute("brain");
|
const brain = this.getAttribute("brain");
|
||||||
const { spec, ...info } = await fetchSpec(this.endpoint, { dataset: this.dataset, prompt, prior: this._spec, ...(brain ? { brain } : {}) });
|
const body = { dataset: this.dataset, prompt, prior: this._spec };
|
||||||
|
if (brain === "fast") return await this.askFast(body);
|
||||||
|
const { spec, ...info } = await fetchSpec(this.endpoint, { ...body, ...(brain ? { brain } : {}) });
|
||||||
await this.render(spec, prompt);
|
await this.render(spec, prompt);
|
||||||
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } }));
|
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -210,6 +213,29 @@ export class AiChart extends Base {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// brain="fast": a decision model draws first (under a second), an LLM answers in the background, and the chart is
|
||||||
|
// redrawn only if the LLM's chart differs. Whichever comes back alone still draws.
|
||||||
|
async askFast(body) {
|
||||||
|
const ask = ++this._asks || (this._asks = 1);
|
||||||
|
const current = () => ask === this._asks;
|
||||||
|
const same = (a, b) => JSON.stringify({ ...a, title: 0 }) === JSON.stringify({ ...b, title: 0 });
|
||||||
|
const llm = fetchSpec(this.endpoint, { ...body, brain: "llm" }).catch((e) => ({ error: e }));
|
||||||
|
let drawn = null;
|
||||||
|
try {
|
||||||
|
const { spec, ...info } = await fetchSpec(this.endpoint, { ...body, brain: "clef", floor: 0 });
|
||||||
|
if (!current()) return;
|
||||||
|
await this.render(spec, body.prompt);
|
||||||
|
drawn = spec;
|
||||||
|
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt: body.prompt, info: { ...info, stage: "fast" } } }));
|
||||||
|
} catch { /* the LLM will answer */ }
|
||||||
|
const out = await llm;
|
||||||
|
if (!current() || out.error) { if (!drawn && out.error) throw out.error; return; }
|
||||||
|
const { spec, ...info } = out;
|
||||||
|
const changed = !drawn || !same(drawn, spec);
|
||||||
|
if (changed) await this.render(spec, body.prompt);
|
||||||
|
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt: body.prompt, info: { ...info, stage: drawn ? (changed ? "corrected" : "confirmed") : "llm" } } }));
|
||||||
|
}
|
||||||
|
|
||||||
async render(spec, prompt) {
|
async render(spec, prompt) {
|
||||||
await this.load();
|
await this.load();
|
||||||
const v = validateSpec(spec, this._schema);
|
const v = validateSpec(spec, this._schema);
|
||||||
|
|||||||
@@ -52,16 +52,18 @@ export function questions(info) {
|
|||||||
x: choice("Which column goes on the x axis (the categories or time the chart is broken down by)?", axis),
|
x: choice("Which column goes on the x axis (the categories or time the chart is broken down by)?", axis),
|
||||||
measure: choice("Which quantity does the chart measure?", measure),
|
measure: choice("Which quantity does the chart measure?", measure),
|
||||||
agg: choice("How should that quantity be combined within each group?", AGG_MENU),
|
agg: choice("How should that quantity be combined within each group?", AGG_MENU),
|
||||||
series: choice("Should the chart split into one coloured series per value of another column? Pick none unless the request compares groups against each other (\"web vs store\", \"by region over time\").",
|
series: choice("Does the request compare two or more values of a column side by side (\"web vs store\", \"split by channel\", \"each region over time\")? If so, pick that column; it becomes one coloured line or bar per value. Otherwise none.",
|
||||||
{ none: "One series only.", ...Object.fromEntries(text.map((c) => [c.name, `one series per ${c.name}`])) }),
|
{ none: "No comparison asked for: a single series.",
|
||||||
|
...Object.fromEntries(text.map((c) => [c.name, `Compare ${c.name}s (${(info.aggregates[c.name]?.values || []).join(" vs ")}): one series per ${c.name}.`])) }),
|
||||||
order: choice("How should the groups be ordered?", ORDER_MENU),
|
order: choice("How should the groups be ordered?", ORDER_MENU),
|
||||||
top: choice("Does the request limit how many groups to show?", TOP_MENU),
|
top: choice("Does the request limit how many groups to show?", TOP_MENU),
|
||||||
};
|
};
|
||||||
for (const c of text) {
|
for (const c of text) {
|
||||||
const vals = info.aggregates[c.name]?.values;
|
const vals = info.aggregates[c.name]?.values;
|
||||||
if (vals?.length && vals.length <= 30)
|
if (vals?.length && vals.length <= 30)
|
||||||
q[`only.${c.name}`] = choice(`Does the request restrict the data to one ${c.name}? Pick any unless it names one.`,
|
q[`only.${c.name}`] = choice(`Does the request name exactly one ${c.name} to look at (for example "${vals[0]} sales" or "in the ${vals[0]}")? Pick that value; pick any if it names none, or names several to compare.`,
|
||||||
{ any: `No restriction on ${c.name}.`, ...Object.fromEntries(vals.map((v) => [v, `Only ${c.name} = ${v}.`])) });
|
{ any: `The request names no single ${c.name}.`,
|
||||||
|
...Object.fromEntries(vals.map((v) => [v, `The request mentions "${v}": keep only rows where ${c.name} is ${v}.`])) });
|
||||||
}
|
}
|
||||||
return q;
|
return q;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,8 +287,8 @@ h2{font-size:18px;margin:28px 0 8px}.row{display:flex;flex-wrap:wrap;gap:8px;ali
|
|||||||
</style></head><body><main>
|
</style></head><body><main>
|
||||||
<h1>AICharts</h1>
|
<h1>AICharts</h1>
|
||||||
<p class="lede">Ask for a chart in plain words. The model writes a small spec that is checked before anything draws, and never sees the rows: only column names, five sample rows and value ranges. Data: a sample tea shop, 24 months of orders.</p>
|
<p class="lede">Ask for a chart in plain words. The model writes a small spec that is checked before anything draws, and never sees the rows: only column names, five sample rows and value ranges. Data: a sample tea shop, 24 months of orders.</p>
|
||||||
<div class="row"><div class="chips" id="chips"></div><label class="brain">Brain <select id="brain"><option value="">default</option><option value="jev">Jev</option><option value="clef">Clef</option><option value="llm">LLM</option></select></label></div>
|
<div class="row"><div class="chips" id="chips"></div><label class="brain">Brain <select id="brain"><option value="fast" selected>Fast (Clef, LLM checks)</option><option value="clef">Clef only</option><option value="jev">Jev</option><option value="llm">LLM only</option></select></label></div>
|
||||||
<ai-chart dataset="orders"></ai-chart>
|
<ai-chart dataset="orders" brain="fast"></ai-chart>
|
||||||
<div class="meta" id="meta"></div>
|
<div class="meta" id="meta"></div>
|
||||||
<details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details>
|
<details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details>
|
||||||
<h2>Saved charts</h2><ul id="saved"><li>None yet.</li></ul>
|
<h2>Saved charts</h2><ul id="saved"><li>None yet.</li></ul>
|
||||||
@@ -310,7 +310,8 @@ brainSel.onchange = () => brainSel.value ? el.setAttribute("brain", brainSel.val
|
|||||||
el.addEventListener("spec", (e) => {
|
el.addEventListener("spec", (e) => {
|
||||||
const { spec, info = {} } = e.detail;
|
const { spec, info = {} } = e.detail;
|
||||||
document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2);
|
document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2);
|
||||||
const bits = [info.brain && "via " + info.brain + (info.handoff ? " (handed off: " + info.handoff.reason + ")" : ""),
|
const stage = { fast: "first draw", confirmed: "LLM agrees", corrected: "corrected by the LLM" }[info.stage];
|
||||||
|
const bits = [stage, info.brain && "via " + info.brain + (info.handoff ? " (handed off: " + info.handoff.reason + ")" : ""),
|
||||||
info.ms != null && info.ms + " ms", info.confidence != null && "confidence " + info.confidence].filter(Boolean);
|
info.ms != null && info.ms + " ms", info.confidence != null && "confidence " + info.confidence].filter(Boolean);
|
||||||
meta.replaceChildren(document.createTextNode(bits.join(" · ")));
|
meta.replaceChildren(document.createTextNode(bits.join(" · ")));
|
||||||
for (const alt of info.alternatives || []) {
|
for (const alt of info.alternatives || []) {
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ prh = {**pr, "head": {"sha": "abc"}}
|
|||||||
chk = {7: {"pr": {**prh, "state": "open"}, "statuses": [{"status": "pending", "context": "ci"}, {"status": "success", "context": "lint"}]}}
|
chk = {7: {"pr": {**prh, "state": "open"}, "statuses": [{"status": "pending", "context": "ci"}, {"status": "success", "context": "lint"}]}}
|
||||||
run(World([card(1, D)], {1: [beat("dispatched", launched=True)]}, [prh], None, chk), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_REVIEW": "venice:kimi"})
|
run(World([card(1, D)], {1: [beat("dispatched", launched=True)]}, [prh], None, chk), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_REVIEW": "venice:kimi"})
|
||||||
assert "# Checks\n- ci: pending\n- lint: success\n\n# Diff" in seen["user"], seen["user"]
|
assert "# Checks\n- ci: pending\n- lint: success\n\n# Diff" in seen["user"], seen["user"]
|
||||||
|
assert "means that context reported success" in seen["system"] and "tests or builds passed" not in seen["system"]
|
||||||
assert "evidence you can rely on" in seen["system"] and "pending check is not yet evidence" in seen["system"]
|
assert "evidence you can rely on" in seen["system"] and "pending check is not yet evidence" in seen["system"]
|
||||||
run(World([card(1, D)], {1: [beat("dispatched", launched=True)]}, [prh]), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_REVIEW": "venice:kimi"})
|
run(World([card(1, D)], {1: [beat("dispatched", launched=True)]}, [prh]), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_REVIEW": "venice:kimi"})
|
||||||
assert "# Checks\n(checks unavailable)\n\n# Diff" in seen["user"], seen["user"]
|
assert "# Checks\n(checks unavailable)\n\n# Diff" in seen["user"], seen["user"]
|
||||||
@@ -280,6 +281,14 @@ assert not merged(w) and "objected" in w.posted[0][1]
|
|||||||
# protected paths -> human merges
|
# protected paths -> human merges
|
||||||
w = run(pr_world(files=[".github/workflows/ci.yml"]), {**K, "TIME_AUTOMERGE": "on"})
|
w = run(pr_world(files=[".github/workflows/ci.yml"]), {**K, "TIME_AUTOMERGE": "on"})
|
||||||
assert not merged(w) and "protected paths" in w.posted[0][1]
|
assert not merged(w) and "protected paths" in w.posted[0][1]
|
||||||
|
# the default protects all of .github/, the engine included; TIME_PROTECTED can narrow that to the workflow dirs, which stay
|
||||||
|
# protected whatever it says
|
||||||
|
w = run(pr_world(files=[".github/time/time.py", "CHANGELOG.md"]), {**K, "TIME_AUTOMERGE": "on"}); assert not merged(w)
|
||||||
|
NARROW = ".github/workflows/,.gitea/workflows/,deploy,infra/,.env,secret"
|
||||||
|
w = run(pr_world(files=[".github/time/time.py", "CHANGELOG.md"]), {**K, "TIME_AUTOMERGE": "on", "TIME_PROTECTED": NARROW}); assert merged(w)
|
||||||
|
w = run(pr_world(files=[".gitea/workflows/ci.yml"]), {**K, "TIME_AUTOMERGE": "on", "TIME_PROTECTED": "deploy"})
|
||||||
|
assert not merged(w) and "protected paths" in w.posted[0][1]
|
||||||
|
os.environ.pop("TIME_PROTECTED", None)
|
||||||
# failing or missing checks block the merge even when approved
|
# failing or missing checks block the merge even when approved
|
||||||
w = run(pr_world(checks=[{"status": "failure", "context": "ci"}]), {**K, "TIME_AUTOMERGE": "on"})
|
w = run(pr_world(checks=[{"status": "failure", "context": "ci"}]), {**K, "TIME_AUTOMERGE": "on"})
|
||||||
assert not merged(w) and "checks are failing" in w.posted[0][1]
|
assert not merged(w) and "checks are failing" in w.posted[0][1]
|
||||||
@@ -395,6 +404,12 @@ assert h.call("open_pr", {"title": "T", "summary": "round two"}) == "updated PR
|
|||||||
assert w.patched[0][0] == 9 and "round two" in w.patched[0][1]["body"] and "Closes #1" in w.patched[0][1]["body"]
|
assert w.patched[0][0] == 9 and "round two" in w.patched[0][1]["body"] and "Closes #1" in w.patched[0][1]["body"]
|
||||||
h2 = tm.Hands(2, "time/card-2"); h2.label = "kimi"
|
h2 = tm.Hands(2, "time/card-2"); h2.label = "kimi"
|
||||||
assert h2.call("open_pr", {"title": "T", "summary": "s"}) == "opened PR #42" and len(w.opened) == 1, "a closed PR is not reopened"
|
assert h2.call("open_pr", {"title": "T", "summary": "s"}) == "opened PR #42" and len(w.opened) == 1, "a closed PR is not reopened"
|
||||||
|
# no duplicate "Closes #N": added when the summary lacks it, left alone when it has it (#1 is not satisfied by #12)
|
||||||
|
for summ, n in (("s", 1), ("s\n\nCloses #1", 1), ("s\n\ncloses #1.", 1), ("s\n\nCloses #12", 2)):
|
||||||
|
w = World([], prs=[{"number": 9, "state": "open", "head": {"ref": "time/card-1"}}]); tm.http = w.http
|
||||||
|
h = tm.Hands(1, "time/card-1"); h.label = "kimi"; h.call("open_pr", {"title": "T", "summary": summ})
|
||||||
|
body = w.patched[0][1]["body"]
|
||||||
|
assert body.lower().count("closes #1") == n and body.count("Closes #1\n") + body.count("Closes #1.") <= 1, (summ, body)
|
||||||
# the Cursor worker answers an objection too: on the PR's own branch, no new PR, the review text in the prompt
|
# the Cursor worker answers an objection too: on the PR's own branch, no new PR, the review text in the prompt
|
||||||
w = objected_world()
|
w = objected_world()
|
||||||
w = run(w, {**K, "TIME_WORKER": "cursor"})
|
w = run(w, {**K, "TIME_WORKER": "cursor"})
|
||||||
|
|||||||
@@ -542,7 +542,9 @@ def verdict(pr: dict, since: str) -> str | None:
|
|||||||
|
|
||||||
def touches_protected(pr_number: int) -> list[str]:
|
def touches_protected(pr_number: int) -> list[str]:
|
||||||
paths = [p.strip() for p in (os.environ.get("TIME_PROTECTED") or DEFAULT_PROTECTED).split(",") if p.strip()]
|
paths = [p.strip() for p in (os.environ.get("TIME_PROTECTED") or DEFAULT_PROTECTED).split(",") if p.strip()]
|
||||||
paths = list(dict.fromkeys([".github/", ".gitea/", *paths])) # workflows stay a human's merge whatever TIME_PROTECTED says
|
# workflows stay a human's merge whatever TIME_PROTECTED says; the rest is TIME_PROTECTED's call: the default keeps all
|
||||||
|
# of .github/, and a repo that trusts its reviewer can narrow it so the engine under .github/time/ lands like other code
|
||||||
|
paths = list(dict.fromkeys([".github/workflows/", ".gitea/workflows/", *paths]))
|
||||||
files, page = [], 1
|
files, page = [], 1
|
||||||
while True: # every file, not the first 100; a rename counts under both names
|
while True: # every file, not the first 100; a rename counts under both names
|
||||||
batch = gh("GET", f"/repos/{REPO}/pulls/{pr_number}/files?limit=100&page={page}")
|
batch = gh("GET", f"/repos/{REPO}/pulls/{pr_number}/files?limit=100&page={page}")
|
||||||
@@ -875,7 +877,8 @@ class Hands:
|
|||||||
note = ("\n" + "; ".join(kept)) if kept else ""
|
note = ("\n" + "; ".join(kept)) if kept else ""
|
||||||
return f"exit {p.returncode}\n{(p.stdout + p.stderr)[-6000:]}{note}"
|
return f"exit {p.returncode}\n{(p.stdout + p.stderr)[-6000:]}{note}"
|
||||||
if name == "open_pr":
|
if name == "open_pr":
|
||||||
body = f"{a['summary']}\n\nCloses #{self.n}\n\n<sub>worker: {self.label} via Time</sub>"
|
closes = "" if re.search(rf"\bcloses #{self.n}(?!\d)", a["summary"], re.I) else f"\n\nCloses #{self.n}" # once only
|
||||||
|
body = f"{a['summary']}{closes}\n\n<sub>worker: {self.label} via Time</sub>"
|
||||||
if (old := open_pr_for(self.branch)): # a revision: the PR is open already, so say what changed there
|
if (old := open_pr_for(self.branch)): # a revision: the PR is open already, so say what changed there
|
||||||
gh("PATCH", f"/repos/{REPO}/pulls/{old['number']}", {"title": a["title"][:200], "body": body}, as_time=True)
|
gh("PATCH", f"/repos/{REPO}/pulls/{old['number']}", {"title": a["title"][:200], "body": body}, as_time=True)
|
||||||
self.pr = old["number"]
|
self.pr = old["number"]
|
||||||
@@ -1026,7 +1029,7 @@ def review(card: dict, pr: dict, done: str) -> dict:
|
|||||||
+ REVIEW_PROMPT.format(pr=pr["number"], repo=REPO, n=card["number"], title=card["title"], done=done)
|
+ REVIEW_PROMPT.format(pr=pr["number"], repo=REPO, n=card["number"], title=card["title"], done=done)
|
||||||
.replace("Leave one PR comment that", "Your reply is posted as the PR comment. It")
|
.replace("Leave one PR comment that", "Your reply is posted as the PR comment. It")
|
||||||
+ "\nThe checks listed under # Checks are the head commit's real statuses: evidence you can rely on "
|
+ "\nThe checks listed under # Checks are the head commit's real statuses: evidence you can rely on "
|
||||||
"(a success there means those tests or builds passed). A pending check is not yet evidence either way.")
|
"(a success there means that context reported success). A pending check is not yet evidence either way.")
|
||||||
text = think(seat, model, effort, system, user)
|
text = think(seat, model, effort, system, user)
|
||||||
gh("POST", f"/repos/{REPO}/issues/{pr['number']}/comments",
|
gh("POST", f"/repos/{REPO}/issues/{pr['number']}/comments",
|
||||||
{"body": f"{text}\n\n<sub>review by {seat} · {model or SEATS[seat]['default']} · via Time</sub>"})
|
{"body": f"{text}\n\n<sub>review by {seat} · {model or SEATS[seat]['default']} · via Time</sub>"})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
(startsWith(github.event.comment.body, '**Approve') || startsWith(github.event.comment.body, 'Approve') ||
|
(startsWith(github.event.comment.body, '**Approve') || startsWith(github.event.comment.body, 'Approve') ||
|
||||||
startsWith(github.event.comment.body, '**Object') || startsWith(github.event.comment.body, 'Object'))))
|
startsWith(github.event.comment.body, '**Object') || startsWith(github.event.comment.body, 'Object'))))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 55 # workers can overlap inside one job now (#75); under the hourly cadence, and the concurrency group queues the next run
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -37,6 +37,11 @@ jobs:
|
|||||||
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
|
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # user-level secret on teahubxyz (docs/decisions.md on the spine)
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # user-level secret on teahubxyz (docs/decisions.md on the spine)
|
||||||
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # repo secret: the `time` bot opens and merges PRs (Gitea denies that to the Actions identity)
|
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # repo secret: the `time` bot opens and merges PRs (Gitea denies that to the Actions identity)
|
||||||
|
TIME_SEAT_CLAUDE: ${{ secrets.TIME_SEAT_CLAUDE }} # umbrella seat, retired once the per-seat tokens below are in use (#76)
|
||||||
|
TIME_SEAT_HAIKU: ${{ secrets.TIME_SEAT_HAIKU }} # one Gitea user per seat (model line): a seat's verdict is a real review by its own user (#76)
|
||||||
|
TIME_SEAT_SONNET: ${{ secrets.TIME_SEAT_SONNET }}
|
||||||
|
TIME_SEAT_OPUS: ${{ secrets.TIME_SEAT_OPUS }}
|
||||||
|
TIME_SEAT_FABLE: ${{ secrets.TIME_SEAT_FABLE }}
|
||||||
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }} # org owners etc.; collaborators are trusted without being listed
|
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }} # org owners etc.; collaborators are trusted without being listed
|
||||||
TIME_RUN_IMAGE: node:22-bookworm # the worker's shell runs in a throwaway container, not next to these secrets
|
TIME_RUN_IMAGE: node:22-bookworm # the worker's shell runs in a throwaway container, not next to these secrets
|
||||||
TIME_MODE: ${{ vars.TIME_MODE }}
|
TIME_MODE: ${{ vars.TIME_MODE }}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
- The second fix round escalates to Kimi's code model (`venice:kimi code`) instead of grok 4.7: still a different model from the first round, at a fraction of the cost. `TIME_VENTURE_ESCALATE` overrides it.
|
- The second fix round escalates to Kimi's code model (`venice:kimi code`) instead of grok 4.7: still a different model from the first round, at a fraction of the cost. `TIME_VENTURE_ESCALATE` overrides it.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- `TIME_PROTECTED` now governs every protected path except the workflow directories (`.github/workflows/`, `.gitea/workflows/`), which stay a human's merge whatever it says. The default still protects all of `.github/`; a repo that trusts its reviewer can set `TIME_PROTECTED=.github/workflows/,.gitea/workflows/,deploy,infra/,.env,secret` so the engine under `.github/time/` merges itself on Approve + green like any other code.
|
||||||
- Objections go back to the worker. `/time retry` on a card whose PR is open hands the worker the reviewer's latest comments (marked as the reviewer's, strangers' left out) on the same branch; `open_pr` on an open PR updates its description instead of failing; the card goes back to `dispatched`, so the next beat reviews again and an objection older than the push no longer counts.
|
- Objections go back to the worker. `/time retry` on a card whose PR is open hands the worker the reviewer's latest comments (marked as the reviewer's, strangers' left out) on the same branch; `open_pr` on an open PR updates its description instead of failing; the card goes back to `dispatched`, so the next beat reviews again and an objection older than the push no longer counts.
|
||||||
- A batch's children start in the tick that spawned them (GitHub lists a card made a moment ago only later, so they used to wait a tick).
|
- A batch's children start in the tick that spawned them (GitHub lists a card made a moment ago only later, so they used to wait a tick).
|
||||||
- Build briefs keep claims to what the code does: no promised features or schedules, and the trending repos are the wave a venture rides, never something it "builds on". Fix rounds take a failing claim off the page rather than building a feature in a hurry. In the first live batch, every fix round was a claim the code didn't back.
|
- Build briefs keep claims to what the code does: no promised features or schedules, and the trending repos are the wave a venture rides, never something it "builds on". Fix rounds take a failing claim off the page rather than building a feature in a hurry. In the first live batch, every fix round was a claim the code didn't back.
|
||||||
|
|||||||
@@ -193,3 +193,43 @@
|
|||||||
{"at":"2026-10-17T18:27:55Z","model":"claude-sonnet-5-5","in":9230,"out":918,"usd":0.02764}
|
{"at":"2026-10-17T18:27:55Z","model":"claude-sonnet-5-5","in":9230,"out":918,"usd":0.02764}
|
||||||
{"at":"2026-10-17T18:27:59Z","model":"claude-sonnet-5-5","in":10126,"out":226,"usd":0.022512}
|
{"at":"2026-10-17T18:27:59Z","model":"claude-sonnet-5-5","in":10126,"out":226,"usd":0.022512}
|
||||||
{"at":"2026-10-17T18:28:11Z","model":"claude-haiku-5-5","in":2565,"out":1767,"usd":0.00114}
|
{"at":"2026-10-17T18:28:11Z","model":"claude-haiku-5-5","in":2565,"out":1767,"usd":0.00114}
|
||||||
|
{"at":"2026-10-17T20:20:54Z","model":"claude-sonnet-5-5","in":2158,"out":26,"usd":0.004576}
|
||||||
|
{"at":"2026-10-17T20:20:56Z","model":"claude-sonnet-5-5","in":2637,"out":163,"usd":0.006904}
|
||||||
|
{"at":"2026-10-17T20:20:58Z","model":"claude-sonnet-5-5","in":3153,"out":187,"usd":0.008176}
|
||||||
|
{"at":"2026-10-17T20:21:01Z","model":"claude-sonnet-5-5","in":5843,"out":191,"usd":0.013596}
|
||||||
|
{"at":"2026-10-17T20:21:06Z","model":"claude-sonnet-5-5","in":6251,"out":341,"usd":0.015912}
|
||||||
|
{"at":"2026-10-17T20:21:09Z","model":"claude-sonnet-5-5","in":6655,"out":381,"usd":0.01712}
|
||||||
|
{"at":"2026-10-17T20:21:16Z","model":"claude-sonnet-5-5","in":7036,"out":659,"usd":0.020662}
|
||||||
|
{"at":"2026-10-17T20:21:20Z","model":"claude-sonnet-5-5","in":7483,"out":397,"usd":0.018936}
|
||||||
|
{"at":"2026-10-17T20:21:37Z","model":"claude-sonnet-5-5","in":7960,"out":1855,"usd":0.03447}
|
||||||
|
{"at":"2026-10-17T20:21:49Z","model":"claude-sonnet-5-5","in":10584,"out":1157,"usd":0.032738}
|
||||||
|
{"at":"2026-10-17T20:21:57Z","model":"claude-sonnet-5-5","in":11307,"out":837,"usd":0.030984}
|
||||||
|
{"at":"2026-10-17T20:22:04Z","model":"claude-sonnet-5-5","in":16615,"out":513,"usd":0.03836}
|
||||||
|
{"at":"2026-10-17T20:22:17Z","model":"claude-sonnet-5-5","in":19010,"out":942,"usd":0.04744}
|
||||||
|
{"at":"2026-10-17T20:22:23Z","model":"claude-sonnet-5-5","in":19216,"out":477,"usd":0.043202}
|
||||||
|
{"at":"2026-10-17T20:22:33Z","model":"claude-sonnet-5-5","in":19515,"out":970,"usd":0.04873}
|
||||||
|
{"at":"2026-10-17T20:22:45Z","model":"claude-sonnet-5-5","in":19943,"out":1162,"usd":0.051506}
|
||||||
|
{"at":"2026-10-17T20:22:47Z","model":"claude-sonnet-5-5","in":22260,"out":116,"usd":0.04568}
|
||||||
|
{"at":"2026-10-17T20:22:50Z","model":"claude-sonnet-5-5","in":22837,"out":187,"usd":0.047544}
|
||||||
|
{"at":"2026-10-17T20:22:54Z","model":"claude-sonnet-5-5","in":23284,"out":220,"usd":0.048768}
|
||||||
|
{"at":"2026-10-17T20:22:56Z","model":"claude-sonnet-5-5","in":24921,"out":165,"usd":0.051492}
|
||||||
|
{"at":"2026-10-17T20:22:58Z","model":"claude-sonnet-5-5","in":25358,"out":70,"usd":0.051416}
|
||||||
|
{"at":"2026-10-17T20:23:00Z","model":"claude-sonnet-5-5","in":27711,"out":210,"usd":0.057522}
|
||||||
|
{"at":"2026-10-17T20:23:04Z","model":"claude-sonnet-5-5","in":31018,"out":347,"usd":0.065506}
|
||||||
|
{"at":"2026-10-17T20:23:08Z","model":"claude-sonnet-5-5","in":33756,"out":253,"usd":0.070042}
|
||||||
|
{"at":"2026-10-17T20:23:10Z","model":"claude-sonnet-5-5","in":35236,"out":161,"usd":0.072082}
|
||||||
|
{"at":"2026-10-17T20:23:16Z","model":"claude-sonnet-5-5","in":37623,"out":401,"usd":0.079256}
|
||||||
|
{"at":"2026-10-17T20:23:22Z","model":"claude-sonnet-5-5","in":40686,"out":229,"usd":0.083662}
|
||||||
|
{"at":"2026-10-17T20:23:23Z","model":"claude-sonnet-5-5","in":42769,"out":101,"usd":0.086548}
|
||||||
|
{"at":"2026-10-17T20:23:26Z","model":"claude-sonnet-5-5","in":45100,"out":186,"usd":0.09206}
|
||||||
|
{"at":"2026-10-17T20:23:28Z","model":"claude-sonnet-5-5","in":47113,"out":261,"usd":0.096836}
|
||||||
|
{"at":"2026-10-17T20:23:32Z","model":"claude-sonnet-5-5","in":49280,"out":332,"usd":0.10188}
|
||||||
|
{"at":"2026-10-17T20:23:34Z","model":"claude-sonnet-5-5","in":51809,"out":190,"usd":0.105518}
|
||||||
|
{"at":"2026-10-17T20:23:37Z","model":"claude-sonnet-5-5","in":53813,"out":206,"usd":0.109686}
|
||||||
|
{"at":"2026-10-17T20:23:38Z","model":"claude-sonnet-5-5","in":54057,"out":85,"usd":0.108964}
|
||||||
|
{"at":"2026-10-17T20:23:40Z","model":"claude-sonnet-5-5","in":54152,"out":114,"usd":0.109444}
|
||||||
|
{"at":"2026-10-17T20:23:43Z","model":"claude-sonnet-5-5","in":54408,"out":310,"usd":0.111916}
|
||||||
|
{"at":"2026-10-17T20:23:47Z","model":"claude-sonnet-5-5","in":55850,"out":193,"usd":0.11363}
|
||||||
|
{"at":"2026-10-17T20:23:49Z","model":"claude-sonnet-5-5","in":58100,"out":213,"usd":0.11833}
|
||||||
|
{"at":"2026-10-17T20:23:53Z","model":"claude-sonnet-5-5","in":60500,"out":471,"usd":0.12571}
|
||||||
|
{"at":"2026-10-17T20:23:59Z","model":"claude-sonnet-5-5","in":62407,"out":618,"usd":0.130994}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"name": "mdeck",
|
||||||
|
"description": "Make slide decks as .mdeck links, plain-text slides that open at mdecks.yolopreneur.lol with the whole deck inside the link and nothing uploaded. Use when asked for slides, a deck or a presentation to open in a browser or share as a link.",
|
||||||
|
"url": "https://mdecks.yolopreneur.lol/SKILL.md",
|
||||||
|
"files": ["SKILL.md"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>.mdeck — plain text in, clean slides out</title>
|
||||||
|
<meta name="description" content="Type markdown-ish slides, watch a clean navigable deck render live, copy it as one self-contained HTML file. Free, runs entirely in your browser.">
|
||||||
|
<link rel="alternate" type="text/markdown" href="/SKILL.md">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #171a21;
|
||||||
|
--panel-2: #1e222b;
|
||||||
|
--ink: #e8eaf0;
|
||||||
|
--ink-dim: #9aa1af;
|
||||||
|
--accent: #6ea8fe;
|
||||||
|
--accent-2: #9d7bff;
|
||||||
|
--line: #2a2f3a;
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 18px 20px 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
header .left {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
header h1 .dot { color: var(--accent); }
|
||||||
|
header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
header .cta {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||||
|
color: #0b0d12;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
header .cta:hover { filter: brightness(1.08); }
|
||||||
|
.early-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 420px) 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 20px 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.editor-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.editor-pane label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 220px;
|
||||||
|
resize: none;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
textarea:focus { border-color: var(--accent); }
|
||||||
|
.hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.hint code {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--accent); }
|
||||||
|
button:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
button.primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||||
|
color: #0b0d12;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.counter {
|
||||||
|
margin-left: auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
height: 5px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 99px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.progress > div {
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||||||
|
transition: width 180ms ease;
|
||||||
|
}
|
||||||
|
.slide-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 500px at 80% -10%, rgba(110,168,254,0.10), transparent),
|
||||||
|
radial-gradient(900px 500px at 0% 110%, rgba(157,123,255,0.10), transparent),
|
||||||
|
var(--panel);
|
||||||
|
overflow-y: auto;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
.slide {
|
||||||
|
--fit: 1;
|
||||||
|
padding: clamp(22px, 5vw, 56px);
|
||||||
|
min-height: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.slide h1 {
|
||||||
|
font-size: calc(clamp(1.6rem, 4.5vw, 3rem) * var(--fit));
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.slide h2 {
|
||||||
|
font-size: calc(clamp(1.2rem, 3vw, 1.9rem) * var(--fit));
|
||||||
|
margin: 0 0 0.6em;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.slide ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.3em;
|
||||||
|
font-size: calc(clamp(1rem, 2.2vw, 1.35rem) * var(--fit));
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.slide p {
|
||||||
|
font-size: calc(clamp(1rem, 2.2vw, 1.35rem) * var(--fit));
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0.3em 0;
|
||||||
|
}
|
||||||
|
.slide a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.slide a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.slide .empty { color: var(--ink-dim); font-style: italic; }
|
||||||
|
.slide code, .export-note code {
|
||||||
|
background: rgba(110,168,254,0.12);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.fit-note {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.fit-note.warn { color: #f2c46d; }
|
||||||
|
.export-note {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
padding: 40px 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
section h2 {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.step h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.repos {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.repo {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.repo h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.repo h3 a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.repo h3 a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.stars {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.stars b {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.tiers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
.tier {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
.tier h3 {
|
||||||
|
margin: 0 0 15px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
.tier .price {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin: 0 0 15px;
|
||||||
|
}
|
||||||
|
.tier ul {
|
||||||
|
padding-left: 1.3em;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
.tier .btn {
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.nothing-charged {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
margin: 20px 0 0;
|
||||||
|
}
|
||||||
|
.limits {
|
||||||
|
padding-left: 1.3em;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
.limits li {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
footer a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
textarea { min-height: 150px; flex: none; }
|
||||||
|
.slide-wrap { min-height: 300px; }
|
||||||
|
.steps, .repos {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.tiers {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
header .left {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* finish: a one-line header, the product as the whole first screen, and the sections flowing after it (never over it) */
|
||||||
|
html, body { height: auto; }
|
||||||
|
body { display: block; }
|
||||||
|
header { flex-direction: row; align-items: center; gap: 6px 14px; padding: 12px 20px; border-bottom: 1px solid var(--line); }
|
||||||
|
header h1 { font-size: 1.4rem; letter-spacing: -0.01em; }
|
||||||
|
header .promise { margin: 0; color: var(--ink-dim); font-size: 0.9rem; }
|
||||||
|
header .early-note { margin: 0 0 0 auto; font-size: 0.75rem; color: var(--ink-dim); }
|
||||||
|
header .agent { padding: 6px 12px; font-size: 0.8rem; white-space: nowrap; }
|
||||||
|
main { flex: none; height: calc(100svh - 60px); min-height: 460px; }
|
||||||
|
main section { padding: 0; max-width: none; margin: 0; }
|
||||||
|
.hint code { white-space: nowrap; }
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
header { padding: 10px 16px; }
|
||||||
|
header .early-note { margin: 0; flex: 1 1 150px; }
|
||||||
|
header .agent { order: 1; }
|
||||||
|
main { height: auto; min-height: 0; grid-template-rows: auto auto; padding: 12px 16px 16px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1><span class="dot">.</span>mdeck</h1>
|
||||||
|
<p class="promise">Plain text in, slides out.</p>
|
||||||
|
<button id="agent" class="agent">Copy prompt for your agent</button>
|
||||||
|
<p class="early-note">Opens a public GitHub issue. No email, no payment.</p>
|
||||||
|
<a class="cta" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+.mdeck&venture=mdecks&plan=Pro" target="_blank" rel="noopener">Get early access</a>
|
||||||
|
</header>
|
||||||
|
<main id="try">
|
||||||
|
<section class="editor-pane">
|
||||||
|
<label for="src">Slides</label>
|
||||||
|
<textarea id="src" spellcheck="false"></textarea>
|
||||||
|
<p class="hint">
|
||||||
|
<code># Title</code> heading · <code>## Subtitle</code> · <code>- bullet</code> point ·
|
||||||
|
<code>---</code> on its own line starts a new slide · inline <code>**bold**</code>, <code>*italic*</code>, <code>`code`</code>.
|
||||||
|
Plain lines become paragraphs.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="stage">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="prev" aria-label="Previous slide">← Prev</button>
|
||||||
|
<button id="next" aria-label="Next slide">Next →</button>
|
||||||
|
<button id="copy" class="primary">Copy deck as HTML</button>
|
||||||
|
<button id="link">Copy link</button>
|
||||||
|
<span class="counter" id="counter">1 / 1</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" role="progressbar" aria-label="Deck progress"><div id="bar"></div></div>
|
||||||
|
<div class="slide-wrap" id="wrap"><article class="slide" id="slide"></article></div><p class="fit-note" id="fitnote" role="status" hidden></p><p class="fit-note" id="linknote" role="status" hidden></p>
|
||||||
|
<p class="export-note">The copied file is a self-contained deck: open it in any browser and use ← / → to present. A copied link carries the deck inside it, after the #, so it opens here with nothing stored.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section id="how">
|
||||||
|
<h2>How it works</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<h3>Type your slides</h3>
|
||||||
|
<p>Markdown-ish text in the editor: headings, bullets, paragraphs, <code>---</code> between slides. The syntax is the stuff you already type.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>Watch it set live</h3>
|
||||||
|
<p>Every keystroke re-typesets the deck on the right. Navigate with arrow keys or swipe; the progress bar keeps your place.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>Take the file with you</h3>
|
||||||
|
<p>One button copies the deck as a single self-contained HTML file. Open it anywhere, present it offline, email it, done. Or copy a link with the whole deck inside it.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>Or ask your agent</h3>
|
||||||
|
<p>Press <b>Copy prompt for your agent</b> at the top, paste it into Claude, ChatGPT, Cursor or any other agent, and ask for a deck. It answers with a link that opens here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="why">
|
||||||
|
<h2>Why this exists</h2>
|
||||||
|
<div class="repos">
|
||||||
|
<div class="repo">
|
||||||
|
<h3><a href="https://github.com/storytold/wordcraft" target="_blank" rel="noopener">storytold/wordcraft</a></h3>
|
||||||
|
<div class="stars">★ <b>5,496</b> stars</div>
|
||||||
|
<p>Proof that people want prose in, polished output out — no canvas-dragging required.</p>
|
||||||
|
</div>
|
||||||
|
<div class="repo">
|
||||||
|
<h3><a href="https://github.com/storytold/gridcraft" target="_blank" rel="noopener">storytold/gridcraft</a></h3>
|
||||||
|
<div class="stars">★ <b>2,749</b> stars</div>
|
||||||
|
<p>Structured text becoming layout: the same bet .mdeck makes for slides.</p>
|
||||||
|
</div>
|
||||||
|
<div class="repo">
|
||||||
|
<h3><a href="https://github.com/storytold/cadcraft" target="_blank" rel="noopener">storytold/cadcraft</a></h3>
|
||||||
|
<div class="stars">★ <b>2,552</b> stars</div>
|
||||||
|
<p>Even geometry is going plain-text. Your weekly status deck can too.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="pricing">
|
||||||
|
<h2>Pricing</h2>
|
||||||
|
<div class="tiers">
|
||||||
|
<div class="tier">
|
||||||
|
<h3>Free</h3>
|
||||||
|
<div class="price">$0</div>
|
||||||
|
<p>For anyone who needs a quick deck right now.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Full plain-text editor</li>
|
||||||
|
<li>Live navigable preview with arrow keys and progress bar</li>
|
||||||
|
<li>One-click copy of the deck as self-contained HTML</li>
|
||||||
|
<li>Links with the deck inside them, nothing stored</li>
|
||||||
|
<li>Runs entirely in the browser; nothing is saved or sent.</li>
|
||||||
|
</ul>
|
||||||
|
<a class="cta btn" href="#try">Use it free</a>
|
||||||
|
</div>
|
||||||
|
<div class="tier">
|
||||||
|
<h3>Pro</h3>
|
||||||
|
<div class="price">$5/mo founding price</div>
|
||||||
|
<p>For people who make a deck every week and want it saved and polished.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Saved decks, themes, speaker notes</li>
|
||||||
|
<li>PDF export</li>
|
||||||
|
<li>Plain text in, always.</li>
|
||||||
|
</ul>
|
||||||
|
<a class="cta btn" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+.mdeck&venture=mdecks&plan=Pro" target="_blank" rel="noopener">Join early access</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="nothing-charged">Nothing is charged today.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="limits">
|
||||||
|
<h2>Honest limits</h2>
|
||||||
|
<ul class="limits">
|
||||||
|
<li>Nothing is stored — close the tab without copying a link or the HTML and your text is gone.</li>
|
||||||
|
<li>Anyone with a link can read the deck in it, and a very long deck makes a link some apps cut short.</li>
|
||||||
|
<li>One layout and one typeface; no themes or branding.</li>
|
||||||
|
<li>No images, tables, or nested lists yet.</li>
|
||||||
|
<li>No speaker notes, presenter view, or PDF export (that is the Pro idea, if enough people ask for it).</li>
|
||||||
|
<li>The copy button needs a modern browser with clipboard access.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>For agents: <a href="/SKILL.md">SKILL.md</a></p>
|
||||||
|
<p>Built by <a href="https://teahub.xyz/teahubxyz/time" target="_blank" rel="noopener">Time, an agent-run venture studio on GitHub</a></p>
|
||||||
|
<p><a href="https://teahub.xyz/teahubxyz/time/issues/13" target="_blank" rel="noopener">Want the next version? 👍 the signal issue</a></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# .mdeck
|
||||||
|
|
||||||
|
> Type slides in plain text and get a clean presentation in the browser. A deck travels inside its link (after the #), so nothing is uploaded and no account is needed.
|
||||||
|
|
||||||
|
Read SKILL.md first and follow it: it says how to write a deck and turn it into a link that opens in the editor.
|
||||||
|
|
||||||
|
- [SKILL.md](https://mdecks.yolopreneur.lol/SKILL.md): the skill, how an agent makes .mdeck links
|
||||||
|
- [Editor](https://mdecks.yolopreneur.lol/): where a deck link opens; type or paste slides, present with the arrow keys
|
||||||
Reference in new issue
Block a user