Skip to content

Commit

Ask answers "what shipped today" for the asker's own today, and says what changed rather than only how much. "Today" begins at the asker's local midnight: integrations reads the time zone on their profile, or failing that the one their browser sends with the question (the Ask panel and the Chat composer send it; chat keeps it on a question to @g1t, an IANA name only), works out with the runtime's Intl when their day began in UTC (local_day.rs, tested across zones west and east of UTC and on a half hour), and the system prompt says "they are in America/Denver, where it is 2026-10-12: today means since 2026-10-12T06:00:00Z"; without a zone it stays UTC. get_workspace_activity's answer gains `landed`: for the repositories whose default branch was pushed to in the span (busiest first, at most eight), the commits made in the span, one line each (repo, short sha, subject, author, time, link), at most forty, so a workspace that pushes to main rather than merging pull requests still shows what shipped; the prompt has Ask say what those changes were, pass `since` as the start of the asker's today, and narrow the kinds or the span when `complete` is false rather than guessing. A question asked in Chat keeps the direct message's path when its page has none of its own. The quality bar (bench/ask) asks through the direct message with @g1t, as the panel and Chat do now: it opens it, marks a new conversation, posts each question and follows g1t's answer to its end, and grades the steps, sources and proposals it kept. Its first run on production, with tools, had shipped-today pass every check (8/8) and the judge mark it 2.83, for the UTC day and the thin summary this fixes. Checked: cargo test for the workspace (the actions crates' repository-file tests on their own, as on this machine they cannot find their files from the workspace), the wasm build of integrations and the API, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests, and the OpenAPI document regenerated.

syntaqxcommitted Parent38adcb1Browse files
14 files+309−680/14 viewed
+91−4
1616
1717 use g1t_contracts::events::{Event, ListArgs as ListEventsArgs, MAX_LIST_REPOS};
1818 use g1t_contracts::identity::UsernamesArgs;
19−use g1t_contracts::repos::{ListArgs as ListReposArgs, Repo};
19+use g1t_contracts::repos::{Commit, ListArgs as ListReposArgs, LogArgs, Repo, RepoPath};
2020 use g1t_contracts::time::{parse_rfc3339, rfc3339};
2121 use g1t_contracts::work::{MAX_PULLS_FOR_REPOS, PullsForReposArgs, RepoPulls};
2222 use g1t_contracts::{FailureCode, Outcome, Viewer};
4646 ("runs", &["workflow.completed"]),
4747 ];
4848
49+/// The most repositories whose default-branch commits are read, and the
50+/// most commits that `landed` lists across them.
51+const LANDED_REPOS: usize = 8;
52+const LANDED_MAX: usize = 40;
53+/// How far back each of those repositories' history is read.
54+const LANDED_LOG: u32 = 60;
55+
4956 /// What is read when no kinds are given: everything but comments, which
5057 /// would crowd out the rest on a busy day.
5158 const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"];
5259
5360 pub fn description() -> &'static str {
54− "What happened across a workspace's repositories that you can read, newest first: pull requests merged, opened and closed, issues opened and closed, releases published, deployments that succeeded or failed, pushes (with `default_branch` true for what landed on the default branch without a pull request), reviews, packages, and workflow runs that finished (`workflow`, `run_number`, `conclusion`, `run_id` for workflow get_run). Each event has its `kind`, `time`, `repo` (owner/name), `actor` (a username), and where it has them its `number`, `title`, `url` (its page on g1t), `branch`, `commit`, `commits`, `tag` and `error`. `summary` counts merged pull requests, releases, production deployments, default-branch pushes and failed runs: what shipped, and what broke. The span is `since` to `until` (RFC 3339; the last 24 hours when `since` is left out, at most 31 days); narrow it with `kinds` (merged, pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs; all but comments when left out) and `limit` (100 unless you say, at most 200). `complete` is false when more happened than was read: narrow the span or the kinds. For one repository's full timeline use list_events."
61+ "What happened across a workspace's repositories that you can read, newest first: pull requests merged, opened and closed, issues opened and closed, releases published, deployments that succeeded or failed, pushes (with `default_branch` true for what landed on the default branch without a pull request), reviews, packages, and workflow runs that finished (`workflow`, `run_number`, `conclusion`, `run_id` for workflow get_run). Each event has its `kind`, `time`, `repo` (owner/name), `actor` (a username), and where it has them its `number`, `title`, `url` (its page on g1t), `branch`, `commit`, `commits`, `tag` and `error`. `summary` counts merged pull requests, releases, production deployments, default-branch pushes and failed runs: what shipped, and what broke. `landed` lists the commits made on default branches in the span, one line each (`repo`, `commit`, `subject`, `author`, `time`, `url`): what shipped where it was pushed rather than merged. The span is `since` to `until` (RFC 3339; the last 24 hours when `since` is left out, at most 31 days); narrow it with `kinds` (merged, pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs; all but comments when left out) and `limit` (100 unless you say, at most 200). `complete` is false when more happened than was read: narrow the span or the kinds. For one repository's full timeline use list_events."
5562 }
5663
5764 pub fn input() -> Value {
314321 "default_branch_pushes": landed,
315322 "failed_runs": runs_failed,
316323 },
324+ // What landed on default branches in the span, one line a commit
325+ // (`run` fills it in): what shipped where nothing was merged.
326+ "landed": [],
317327 "events": events,
318328 "complete": !found.more && found.repos.len() <= MAX_LIST_REPOS,
319329 })
320330 }
321331
332+/// One repository's default-branch commits made in the span, newest first,
333+/// each as one line: its short sha, the first line of its message, who
334+/// wrote it, when, and its page.
335+pub fn landed(site: &str, repo: &str, commits: &[Commit], since: u64, until: u64) -> Vec<Value> {
336+ let site = site.trim_end_matches('/');
337+ commits
338+ .iter()
339+ .filter(|commit| parse_rfc3339(&commit.authored_at).is_some_and(|at| at >= since && at < until))
340+ .map(|commit| {
341+ let subject: String = commit.message.lines().next().unwrap_or_default().trim().chars().take(160).collect();
342+ json!({
343+ "repo": repo,
344+ "commit": commit.hash.chars().take(12).collect::<String>(),
345+ "subject": subject,
346+ "author": commit.author.name,
347+ "time": commit.authored_at,
348+ "url": format!("{site}/{repo}/commit/{}", commit.hash),
349+ })
350+ })
351+ .collect()
352+}
353+
322354 pub async fn run(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
323355 let workspace = input["workspace"].as_str().unwrap_or_default().trim().to_lowercase();
324356 if workspace.is_empty() {
408440 }
409441 }
410442 let usernames = names.unwrap_or_default();
411− Ok(Outcome::Ok(shape(&Found {
443+ let mut answer = shape(&Found {
412444 workspace: &workspace,
413445 site: &site,
414446 since,
418450 more,
419451 pull_titles: &pull_titles,
420452 usernames: &usernames,
421− })))
453+ });
454+ // The repositories whose default branch was pushed to, busiest first:
455+ // their commits say what landed.
456+ let mut pushed: Vec<(String, usize)> = Vec::new();
457+ for event in events.iter().filter(|event| event.kind == "git.push" && event.data.get("defaultBranch").and_then(Value::as_bool) == Some(true)) {
458+ let Some(id) = event.repo_id.as_deref() else { continue };
459+ match pushed.iter_mut().find(|(known, _)| known == id) {
460+ Some((_, count)) => *count += 1,
461+ None => pushed.push((id.to_owned(), 1)),
462+ }
463+ }
464+ pushed.sort_by(|a, b| b.1.cmp(&a.1));
465+ let by_id: HashMap<&str, &Repo> = repos.iter().map(|repo| (repo.id.as_str(), repo)).collect();
466+ let logs = pushed.iter().take(LANDED_REPOS).filter_map(|(id, _)| by_id.get(id.as_str()).copied()).map(|repo| async move {
467+ let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
468+ let log: Result<Outcome<Vec<Commit>>> =
469+ g1t_kit::call(&services.repos, "log", &LogArgs { path, viewer: viewer.clone(), git_ref: None, limit: LANDED_LOG }).await;
470+ (format!("{}/{}", repo.namespace, repo.name), log)
471+ });
472+ let mut lines = Vec::new();
473+ for (repo, log) in futures_util::future::join_all(logs).await {
474+ // A history that cannot be read leaves the events to say it.
475+ if let Ok(Outcome::Ok(commits)) = log {
476+ lines.extend(landed(&site, &repo, &commits, since, until));
477+ }
478+ }
479+ lines.truncate(LANDED_MAX);
480+ answer["landed"] = json!(lines);
481+ Ok(Outcome::Ok(answer))
422482 }
423483
424484 #[cfg(test)]
473533 }
474534
475535 #[test]
536+ fn what_landed_is_one_line_a_commit_made_in_the_span() {
537+ use g1t_contracts::repos::Signature;
538+ let commit = |hash: &str, message: &str, at: &str| Commit {
539+ hash: hash.to_owned(),
540+ tree_hash: String::new(),
541+ message: message.to_owned(),
542+ author: Signature { name: "Chase".into(), email: String::new() },
543+ parents: Vec::new(),
544+ authored_at: at.to_owned(),
545+ };
546+ let commits = [
547+ commit("aaaaaaaaaaaaaaaa", "Ask reads g1t
548+
549+The long why.", "2026-10-13T05:00:00Z"),
550+ commit("bbbbbbbbbbbbbbbb", "Yesterday's change", "2026-10-12T05:00:00Z"),
551+ ];
552+ let since = parse_rfc3339("2026-10-12T06:00:00Z").unwrap();
553+ let until = parse_rfc3339("2026-10-13T06:00:00Z").unwrap();
554+ let lines = landed("https://g1t.sh/", "acme/web", &commits, since, until);
555+ assert_eq!(lines.len(), 1);
556+ assert_eq!(lines[0]["subject"], "Ask reads g1t");
557+ assert_eq!(lines[0]["commit"], "aaaaaaaaaaaa");
558+ assert_eq!(lines[0]["author"], "Chase");
559+ assert_eq!(lines[0]["url"], "https://g1t.sh/acme/web/commit/aaaaaaaaaaaaaaaa");
560+ }
561+
562+ #[test]
476563 fn what_shipped_reads_as_one_list_with_links_and_names() {
477564 let repos = [repo("rep_1", "web"), repo("rep_2", "api")];
478565 let events = [
+0−0

Binary or large file; its contents are not shown.

+2−1
44
55 import { type AskSetup, type ChatLiveEvent, type ChatMessage, type Result } from "@g1t/contracts";
66
7−import { askPage, askWhere, browserTimeZone } from "../lib/ask";
7+import { askPage, askWhere } from "../lib/ask";
8+import { browserTimeZone } from "../lib/time-zone";
89 import { anyAnswering, applyLive, fromG1t, suggestions } from "../lib/g1t-chat";
910 import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "./ai-elements/conversation";
1011 import { Message, MessageContent } from "./ai-elements/message";
+3−2
4343 import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence";
4444 import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card";
4545
46−import { localTime } from "../../lib/time-zone";
46+import { browserTimeZone, localTime } from "../../lib/time-zone";
4747 import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile";
4848 import { MessageText, type TextContext } from "./text";
4949 // @g1t's answers and its direct message (lib/g1t-chat.ts, components/chat/answer.tsx).
467467 setMessages(put);
468468 }
469469 messageSent({ dnd: dndNow() });
470− const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
470+ // The browser's time zone rides along: g1t keeps it only on a question to @g1t, for what "today" means.
471+ const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root, page: { time_zone: browserTimeZone() } });
471472 const settle = (list: ShownMessage[]) =>
472473 saved.ok
473474 ? mergeMessages(list, [{ ...saved.value, client_id: clientId }])
+0−9
2626 return page;
2727 }
2828
29−/** The browser's time zone, sent with a question so "today" is the asker's today. */
30−export function browserTimeZone(): string | null {
31− try {
32− return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
33− } catch {
34− return null;
35− }
36−}
37−
3829 /** What the composer says about where the question is asked from. */
3930 export function askWhere(page: AskPage): string | null {
4031 if (page.issue) return `On issue #${page.issue}`;
+64−45
165165 // --- Ask ------------------------------------------------------------------
166166
167167 /** Event types the bench knows. Anything else is recorded as unknown, never dropped. */
168−const KNOWN = new Set(["meta", "text", "done", "error"]);
168+const KNOWN = new Set(["meta", "text", "done", "error", "tool", "sources", "proposal"]);
169169
170170 /** Looks like a tool step: `tool`, `tool_call`, `tool_result`, `step`, … */
171171 export const isToolEvent = (event) => /tool|step|action|call/i.test(String(event?.type ?? ""));
182182 return found;
183183 }
184184
185+/** A chat API call on the site as the user: the `{ ok, value | error }` it answers. */
186+async function chatApi(workspace, body) {
187+ const response = await site(`/${workspace}/-/chat/api`, {
188+ method: "POST",
189+ headers: { "content-type": "application/json", origin: SITE },
190+ body: JSON.stringify(body),
191+ });
192+ const text = await response.text();
193+ try {
194+ return JSON.parse(text);
195+ } catch {
196+ return { ok: false, error: { message: `chat api ${response.status}: ${text.slice(0, 200)}` } };
197+ }
198+}
199+
200+async function chatMessages(workspace, channel) {
201+ const response = await site(`/${workspace}/-/chat/api?channel=${encodeURIComponent(channel)}&limit=60`);
202+ const data = await response.json().catch(() => null);
203+ return data?.value?.messages ?? [];
204+}
205+
185206 /**
186− * Asks once, as the user, and reads the whole answer: every event with the
187− * time it came, the time to the first text, the total, the tool steps and
188− * sources it reported, and the text.
207+ * Asks once, as the user, the way Ask and Chat both do: in the user's
208+ * direct message with @g1t, after a New conversation mark, posting each of
209+ * the conversation's questions in turn and following g1t's answer to each
210+ * as it streams (polled). The last answer is the one graded: its text, the
211+ * time to its first words and to its end, and the steps and sources it kept,
212+ * as events in the order the stream gave them.
189213 */
190214 export async function ask(workspace, { messages, page }, { timeoutMs = 180_000 } = {}) {
191215 const started = performance.now();
205229 source_events: [],
206230 source_urls: [],
207231 };
208− const controller = new AbortController();
209− const timer = setTimeout(() => controller.abort(), timeoutMs);
210232 try {
211− const response = await site(`/${workspace}/-/ask`, {
212− method: "POST",
213− headers: { "content-type": "application/json", accept: "application/x-ndjson" },
214− body: JSON.stringify({ messages, page: page ?? {} }),
215− signal: controller.signal,
216− });
217− result.status = response.status;
218− if (!response.body) throw new Error(`no body (${response.status})`);
219− const decoder = new TextDecoder();
220− let pending = "";
221− const take = (line) => {
222− if (!line.trim()) return;
223− let event;
224− try {
225− event = JSON.parse(line);
226− } catch {
227− event = { type: "_unparsed", line: line.slice(0, 500) };
233+ const dm = await chatApi(workspace, { intent: "g1t_dm" });
234+ if (!dm.ok) throw new Error(dm.error?.message ?? "could not open the direct message with g1t");
235+ const channel = dm.value.channel.id;
236+ await chatApi(workspace, { intent: "g1t_reset", channel_id: channel });
237+ const questions = messages.filter((m) => m.role === "user");
238+ let answer = null;
239+ for (const [index, question] of questions.entries()) {
240+ const last = index === questions.length - 1;
241+ const asked = performance.now();
242+ const posted = await chatApi(workspace, { intent: "post", channel_id: channel, body: question.content, page: last ? (page ?? null) : null });
243+ result.status = posted.ok ? 200 : 400;
244+ if (!posted.ok) throw new Error(posted.error?.message ?? "could not post");
245+ const mine = posted.value.id;
246+ answer = null;
247+ while (performance.now() - asked < timeoutMs) {
248+ await sleep(700);
249+ const list = await chatMessages(workspace, channel);
250+ answer = list.find((m) => m.id > mine && m.author?.kind === "system" && m.answer) ?? null;
251+ if (last && answer?.body && result.first_text_ms === null) result.first_text_ms = at();
252+ if (last && answer && result.first_event_ms === null) result.first_event_ms = at();
253+ if (answer && ["done", "error", "stopped"].includes(answer.answer.state)) break;
228254 }
229− const ms = at();
230− result.first_event_ms ??= ms;
231− result.events.push({ ms, event });
232− const type = String(event?.type ?? "");
255+ if (!answer || !["done", "error", "stopped"].includes(answer.answer?.state)) throw new Error(`timed out after ${timeoutMs} ms`);
256+ }
257+ const kept = answer.answer;
258+ result.meta = { type: "meta", provider_label: kept.provider_label, model: kept.model };
259+ result.events.push({ ms: 0, event: result.meta });
260+ for (const step of kept.steps ?? []) {
261+ const type = String(step?.type ?? "");
262+ result.events.push({ ms: null, event: step });
233263 if (!KNOWN.has(type) && !result.unknown_types.includes(type)) result.unknown_types.push(type);
234− if (type === "meta") result.meta = event;
235− if (type === "text" && typeof event.text === "string") {
236− result.first_text_ms ??= ms;
237− result.text += event.text;
238− }
239− if (type === "done") result.done = event;
240− if (type === "error") result.error = event.message ?? "error";
241− if (!KNOWN.has(type) && isToolEvent(event)) result.tool_events.push({ ms, event });
242− if (!KNOWN.has(type) && isSourceEvent(event)) result.source_events.push({ ms, event });
243− };
244− for await (const chunk of response.body) {
245− pending += decoder.decode(chunk, { stream: true });
246− const lines = pending.split("\n");
247− pending = lines.pop() ?? "";
248− lines.forEach(take);
264+ if (isToolEvent(step)) result.tool_events.push({ ms: null, event: step });
265+ if (isSourceEvent(step)) result.source_events.push({ ms: null, event: step });
249266 }
250− take(pending);
267+ result.text = answer.body ?? "";
268+ result.events.push({ ms: at(), event: { type: "text", text: result.text } });
269+ if (kept.state === "done") result.done = { type: "done", stop_reason: kept.stop_reason ?? null };
270+ else result.error = kept.error ?? kept.state;
251271 } catch (error) {
252− result.error = controller.signal.aborted ? `timed out after ${timeoutMs} ms` : String(error?.message ?? error);
272+ result.error = String(error?.message ?? error);
253273 } finally {
254− clearTimeout(timer);
255274 result.total_ms = at();
256275 }
257276 const urls = new Set();
+4−0
524524 pub issue: Option<u32>,
525525 #[serde(default)]
526526 pub pull: Option<u32>,
527+ /// The browser's IANA time zone, such as `America/Denver`: what
528+ /// "today" means when their profile names none.
529+ #[serde(default)]
530+ pub time_zone: Option<String>,
527531 }
528532
529533 /// `ask`: one answer, streamed as newline-delimited JSON [`AskEvent`]s:
+2−0
161161 repo?: RepoPath | null;
162162 issue?: number | null;
163163 pull?: number | null;
164+ /** The browser's IANA time zone, such as `America/Denver`: what "today" means when their profile names none. */
165+ time_zone?: string | null;
164166 };
165167
166168 /** The most rounds of tool calls one answer takes before it must answer. */
+5−0
134134 repo: { namespace: "acme", name: "web" },
135135 issue: null,
136136 pull: null,
137+ time_zone: null,
137138 });
139+ // The browser's time zone is kept when it is one; anything else is dropped.
140+ assert.equal(pageFrom({ path: "/acme", time_zone: "America/Denver" })?.time_zone, "America/Denver");
141+ assert.equal(pageFrom({ path: "/acme", time_zone: "<script>" })?.time_zone, null);
142+ assert.deepEqual(pageFrom({ time_zone: "Asia/Kolkata" }), { path: null, repo: null, issue: null, pull: null, time_zone: "Asia/Kolkata" });
138143 assert.equal(pageFrom({ path: "https://elsewhere" }), null);
139144 assert.equal(pageFrom("nope"), null);
140145 assert.equal(metaColumn({}), null);
+2−1
156156 : null,
157157 issue: number(sent.issue),
158158 pull: number(sent.pull),
159+ time_zone: typeof sent.time_zone === "string" && /^[A-Za-z][A-Za-z0-9_+\-]*(\/[A-Za-z0-9_+\-]+){0,2}$/.test(sent.time_zone) && sent.time_zone.length <= 64 ? sent.time_zone : null,
159160 };
160− return page.path || page.repo ? page : null;
161+ return page.path || page.repo || page.time_zone ? page : null;
161162 }
162163
163164 /** What g1t says when nobody chose an AI provider for Ask yet: once, and where to choose one. */
+2−1
12411241 channel: { id: channel.id, kind: channel.kind, name: channel.name },
12421242 actor: viewer,
12431243 messages,
1244− page: latestPage(rows) ?? { path: `/${place.slug}/-/chat/dm/${channel.id}` },
1244+ // The page of the newest question; one asked in Chat is on the direct message itself.
1245+ page: { ...latestPage(rows), path: latestPage(rows)?.path ?? `/${place.slug}/-/chat/dm/${channel.id}` },
12451246 message: written.value,
12461247 });
12471248 }
+25−3
273273 pub facts: &'a [Fact],
274274 /// Whether g1t's tools could be offered this time.
275275 pub tools: bool,
276+ /// Their time zone, their date there, and when that day began (UTC):
277+ /// what "today" means to them. None when their profile names no zone.
278+ pub local: Option<(&'a str, &'a str, &'a str)>,
276279 }
277280
278281 /// What the provider is told before the conversation.
279282 pub fn system_prompt(context: &Context) -> String {
280− let Context { slug, name, username, site, today, now, page, facts, tools } = *context;
283+ let Context { slug, name, username, site, today, now, page, facts, tools, local } = *context;
281284 let site = site.trim_end_matches('/');
282285 let named = if name.is_empty() || name.eq_ignore_ascii_case(slug) {
283286 slug.to_owned()
288291 "You are g1t, the assistant built into g1t ({site}), where teams keep their repositories, issues, pull \
289292 requests, workflows, deployments, releases and docs. You are answering @{username}, a member of the \
290293 workspace {named}, and you act for them: you can read what they can read in g1t, and nothing more.\n\n\
291− Today is {today} (UTC); it is now {now}. \"Today\" means since {today}T00:00:00Z unless they say otherwise."
294+ It is now {now} (UTC)."
292295 );
296+ match local {
297+ Some((zone, date, started)) => prompt.push_str(&format!(
298+ " They are in {zone}, where it is {date}: \"today\" means since {started} unless they say otherwise, and \"yesterday\" the day before that. Give times in their zone where it helps."
299+ )),
300+ None => prompt.push_str(&format!(" Today is {today} (UTC): \"today\" means since {today}T00:00:00Z unless they say otherwise.")),
301+ }
293302 if tools {
294303 prompt.push_str(&format!(
295304 "\n\n## Reading g1t\n\
299308 - For anything across the workspace (what shipped, what happened today or this week, who did what, \
300309 what is in flight), start with the workspace tool's `activity` action with workspace \"{slug}\" and a \
301310 `since`: one call covers every repository they can read. \"What shipped\" means merged pull requests, \
302− releases, production deployments and pushes to a default branch.\n\
311+ releases, production deployments and pushes to a default branch; its `landed` lists the commits those \
312+ pushes carried, one line each, so say what changed (group related commits, lead with what matters to \
313+ people), not only how many. Pass `since` as the start of their today for \"today\". If `complete` is \
314+ false, narrow `kinds` (such as merged, releases, deployments, pushes) or the span and read again rather \
315+ than guessing.\n\
303316 - To find a repository, use the repository tool's `list` or the search tool. Repositories are \
304317 \"owner/name\", here usually \"{slug}/…\".\n\
305318 - For one pull request, read it with pull_request `get` (status, checks, reviews) and `changes` for \
901914 page,
902915 facts,
903916 tools,
917+ local: None,
904918 }
905919 }
906920
911925 repo: Some(RepoPath { namespace: "acme".into(), name: "web".into() }),
912926 issue: Some(12),
913927 pull: None,
928+ time_zone: None,
914929 };
915930 let facts = [Fact { kind: "issue", number: 12, title: "Login fails".into(), state: "open".into(), body: "Steps…".into() }];
916931 let prompt = system_prompt(&context(&page, &facts, true));
936951 assert!(!bare.contains("## Where they are"));
937952 assert!(bare.contains("could not be reached"));
938953 assert!(!bare.contains("## Reading g1t"));
954+ // In their own zone, "today" began at their midnight.
955+ let local = system_prompt(&Context {
956+ local: Some(("America/Denver", "2026-10-12", "2026-10-12T06:00:00Z")),
957+ ..context(&AskPage::default(), &[], true)
958+ });
959+ assert!(local.contains("They are in America/Denver, where it is 2026-10-12: \"today\" means since 2026-10-12T06:00:00Z"));
960+ assert!(!local.contains("(UTC): \"today\""));
939961 }
940962
941963 #[test]
+19−2
1616 mod github;
1717 mod github_jwt;
1818 mod http;
19+mod local_day;
1920 mod models;
2021 mod refs;
2122 mod rename;
2324 mod trackers;
2425
2526 use g1t_contracts::events::Event;
26−use g1t_contracts::identity::{AskToken, CreateAskTokenArgs, RevokeAskTokenArgs, SlugArgs, Workspace};
27+use g1t_contracts::identity::{AskToken, CreateAskTokenArgs, Profile, RevokeAskTokenArgs, SlugArgs, UsernameArgs, Workspace};
2728 use g1t_contracts::integrations::*;
2829 use g1t_contracts::repos::RepoPath;
2930 use g1t_contracts::time::rfc3339;
710711 let provider = row.provider();
711712 let config = row.config();
712713 let key = self.secrets(&row).secret;
713− let (named, facts, credential) = futures_util::future::join3(
714+ let (named, facts, credential, profile) = futures_util::future::join4(
714715 g1t_kit::call::<_, Option<Workspace>>(&self.identity, "get_workspace", &SlugArgs { slug: workspace.clone() }),
715716 self.ask_facts(&a.actor, &a.page),
716717 g1t_kit::call::<_, Outcome<AskToken>>(
718719 "create_ask_token",
719720 &CreateAskTokenArgs { actor: a.actor.clone(), workspace: workspace.clone() },
720721 ),
722+ // Their time zone, for what "today" means to them.
723+ g1t_kit::call::<_, Option<Profile>>(&self.identity, "profile", &UsernameArgs { username: a.actor.username.clone() }),
721724 )
722725 .await;
723726 let name = named.ok().flatten().map(|found| found.name).unwrap_or_default();
746749 let now = rfc3339(started);
747750 let today: String = now.chars().take(10).collect();
748751 let now: String = format!("{}Z", now.chars().take(19).collect::<String>());
752+ // Their profile's zone first, else the one their browser sent.
753+ let zone = profile
754+ .ok()
755+ .flatten()
756+ .and_then(|profile| profile.timezone)
757+ .or_else(|| a.page.time_zone.clone())
758+ .filter(|zone| !zone.trim().is_empty() && zone.len() <= 64);
759+ let day = zone.as_deref().and_then(|zone| local_day::today(zone, started));
760+ let day_started = day.as_ref().map(local_day::started_at);
761+ let local = match (&zone, &day, &day_started) {
762+ (Some(zone), Some(day), Some(started)) => Some((zone.as_str(), day.date.as_str(), started.as_str())),
763+ _ => None,
764+ };
749765 let username = a.actor.display_username.clone().unwrap_or_else(|| a.actor.username.clone());
750766 let system = ask::system_prompt(&ask::Context {
751767 slug: &workspace,
757773 page: &a.page,
758774 facts: &facts,
759775 tools: !toolbox.is_empty(),
776+ local,
760777 });
761778 let mut answering = ask_loop::Answering::new(
762779 provider,
+90−0
1+//! When the person asking's day began, in their own time zone (their
2+//! profile's IANA name, such as `America/Denver`): what "today" means in a
3+//! question to Ask. The runtime's `Intl` says the wall-clock time there;
4+//! the rest is arithmetic, tested on its own.
5+
6+use g1t_contracts::time::{parse_rfc3339, rfc3339};
7+
8+/// Their day: its date, and when it began as UTC milliseconds.
9+#[derive(Debug, PartialEq, Eq)]
10+pub struct Day {
11+ pub date: String,
12+ pub started: u64,
13+}
14+
15+/// The day in `zone` at `now`, or none when the zone is unknown.
16+pub fn today(zone: &str, now: u64) -> Option<Day> {
17+ from_wallclock(&wallclock(zone, now)?, now)
18+}
19+
20+/// From the wall-clock time in the zone at `now`, written
21+/// `MM/DD/YYYY, HH:MM:SS` (en-US, 24-hour), the day it is there and when it
22+/// began.
23+pub fn from_wallclock(text: &str, now: u64) -> Option<Day> {
24+ let (date, time) = text.split_once(", ")?;
25+ let mut day = date.split('/');
26+ let (month, day_of, year) = (day.next()?, day.next()?, day.next()?);
27+ let date = format!("{year:0>4}-{month:0>2}-{day_of:0>2}");
28+ let wall = parse_rfc3339(&format!("{date}T{}Z", time.trim()))?;
29+ // The zone's offset from UTC, to the minute.
30+ let offset = (wall as i64 - now as i64) / 60_000 * 60_000;
31+ let midnight = parse_rfc3339(&format!("{date}T00:00:00Z"))? as i64;
32+ let started = u64::try_from(midnight - offset).ok()?;
33+ Some(Day { date, started })
34+}
35+
36+/// The day's start as RFC 3339, to the second.
37+pub fn started_at(day: &Day) -> String {
38+ format!("{}Z", rfc3339(day.started).chars().take(19).collect::<String>())
39+}
40+
41+#[cfg(target_arch = "wasm32")]
42+fn wallclock(zone: &str, now: u64) -> Option<String> {
43+ use worker::js_sys::{Array, Date, Function, Object, Reflect, global};
44+ use worker::wasm_bindgen::{JsCast, JsValue};
45+ let options = Object::new();
46+ for (key, value) in [
47+ ("timeZone", zone),
48+ ("hourCycle", "h23"),
49+ ("year", "numeric"),
50+ ("month", "2-digit"),
51+ ("day", "2-digit"),
52+ ("hour", "2-digit"),
53+ ("minute", "2-digit"),
54+ ("second", "2-digit"),
55+ ] {
56+ Reflect::set(&options, &key.into(), &value.into()).ok()?;
57+ }
58+ // Constructed through Reflect so an unknown zone is an error, not a trap.
59+ let intl = Reflect::get(&global(), &"Intl".into()).ok()?;
60+ let class: Function = Reflect::get(&intl, &"DateTimeFormat".into()).ok()?.dyn_into().ok()?;
61+ let formatter = Reflect::construct(&class, &Array::of2(&"en-US".into(), &options)).ok()?;
62+ let format: Function = Reflect::get(&formatter, &"format".into()).ok()?.dyn_into().ok()?;
63+ format.call1(&formatter, &Date::new(&JsValue::from_f64(now as f64))).ok()?.as_string()
64+}
65+
66+#[cfg(not(target_arch = "wasm32"))]
67+fn wallclock(_zone: &str, _now: u64) -> Option<String> {
68+ None
69+}
70+
71+#[cfg(test)]
72+mod tests {
73+ use super::*;
74+
75+ #[test]
76+ fn a_day_begins_at_local_midnight() {
77+ // 05:20 UTC on the 13th is 23:20 on the 12th in Denver (UTC−6).
78+ let now = parse_rfc3339("2026-10-13T05:20:00Z").unwrap();
79+ let day = from_wallclock("10/12/2026, 23:20:00", now).unwrap();
80+ assert_eq!(day.date, "2026-10-12");
81+ assert_eq!(started_at(&day), "2026-10-12T06:00:00Z");
82+ // East of UTC: 05:20 UTC is 14:20 in Tokyo (UTC+9), whose day began at 15:00 UTC the day before.
83+ let day = from_wallclock("10/13/2026, 14:20:00", now).unwrap();
84+ assert_eq!((day.date.as_str(), started_at(&day).as_str()), ("2026-10-13", "2026-10-12T15:00:00Z"));
85+ // Half-hour zones keep their minutes: India (UTC+5:30).
86+ let day = from_wallclock("10/13/2026, 10:50:00", now).unwrap();
87+ assert_eq!(started_at(&day), "2026-10-12T18:30:00Z");
88+ assert!(from_wallclock("nonsense", now).is_none());
89+ }
90+}