diff --git a/crates/mcp-config/src/codex/status.rs b/crates/mcp-config/src/codex/status.rs index 3a19d13..a36287c 100644 --- a/crates/mcp-config/src/codex/status.rs +++ b/crates/mcp-config/src/codex/status.rs @@ -171,6 +171,7 @@ pub struct Tail { /// taken (#364): when the app received the 5-hour and weekly figures, the /// same figures or not. limits_read: bool, + context_read: bool, } impl Tail { @@ -202,6 +203,7 @@ impl Tail { self.resets_at = Some(reset); } self.limits_read |= rate_limits(&value).is_some(); + self.context_read |= value["type"] == "event_msg" && value["payload"]["type"] == "token_count" && value["payload"]["info"].is_object(); } } self.line.clear(); @@ -223,6 +225,10 @@ impl Tail { std::mem::take(&mut self.limits_read) } + pub fn take_context_read(&mut self) -> bool { + std::mem::take(&mut self.context_read) + } + fn hold(&mut self, part: &[u8]) { if self.skipping { return; @@ -284,6 +290,16 @@ mod tests { dir.join("rollout.jsonl") } + #[test] + fn context_receipt_tracks_info_without_rate_limits_once() { + let mut tail = Tail::default(); + tail.feed(b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"last_token_usage\":{\"total_tokens\":18000},\"model_context_window\":72000}}}\n"); + assert!(tail.take_context_read()); + assert!(!tail.take_context_read()); + assert!(!tail.take_limits_read()); + assert!(tail.status.context.is_some()); + } + #[test] fn reads_all_five_from_the_observed_lines() { let mut tail = Tail::new(0); diff --git a/crates/mcp-config/src/usage.rs b/crates/mcp-config/src/usage.rs index 1805939..69ee2ab 100644 --- a/crates/mcp-config/src/usage.rs +++ b/crates/mcp-config/src/usage.rs @@ -40,6 +40,7 @@ pub struct Usage { #[derive(Debug, Default)] pub struct Book { seats: HashMap<(String, String), Usage>, + contexts: HashMap<(String, String), (Option, i64)>, } impl Book { @@ -51,9 +52,30 @@ impl Book { self.seats.get(&(topic.into(), account.into())) } + /// A context belongs to this launch; the account-wide limits may survive it. + pub fn begin_session(&mut self, topic: &str, account: &str) { + self.contexts.remove(&(topic.into(), account.into())); + } + + pub fn record_context(&mut self, topic: &str, account: &str, context: Option, received_at: i64) { + self.contexts.insert((topic.into(), account.into()), (context, received_at)); + } + + pub fn answer(&self, request_id: &str, topic: &str, account: &str) -> Value { + let mut frame = answer(request_id, self.get(topic, account)); + if let Some((context, at)) = self.contexts.get(&(topic.into(), account.into())) { + if let Some(context) = context.filter(|n| n.is_finite()) { + frame["context"] = json!(context); + } + frame["context_received_at"] = json!(at); + } + frame + } + /// The topic is deleted: its seats' figures go with it. pub fn forget_topic(&mut self, topic: &str) { self.seats.retain(|(t, _), _| t != topic); + self.contexts.retain(|(t, _), _| t != topic); } } @@ -168,6 +190,27 @@ mod tests { assert_eq!(book.get("kept", "lin"), Some(&usage(2))); } + #[test] + fn context_is_seat_scoped_replaced_and_cleared_on_new_launch() { + let mut book = Book::default(); + book.record("t", "a", usage(100)); + book.record_context("t", "a", Some(42.0), 120); + book.record_context("t", "b", Some(1.0), 130); + assert_eq!(book.answer("r", "t", "a")["context"], 42.0); + assert_eq!(book.answer("r", "t", "a")["context_received_at"], 120); + book.record_context("t", "a", None, 140); + assert!(book.answer("r", "t", "a").get("context").is_none()); + book.record_context("t", "a", Some(0.0), 150); + assert_eq!(book.answer("r", "t", "a")["context"], 0.0); + book.begin_session("t", "a"); + let frame = book.answer("r", "t", "a"); + assert!(frame.get("context_received_at").is_none()); + assert_eq!(frame["received_at"], 100); + assert_eq!(book.answer("r", "t", "b")["context"], 1.0); + book.forget_topic("t"); + assert!(book.answer("r", "t", "b").get("context").is_none()); + } + #[test] fn a_refusal_names_why() { let frame = refusal("r", "no account"); diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 9436e21..675c398 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -138,3 +138,7 @@ Claude の親が利用上限で止まり、拒否が解除予定を名指して ### アカウントのニックネーム(#383) 任意のニックネームを設定・保存できる。普段の呼びかけはニックネームで、元の名前はその呼び名がどのアカウントかを認識する情報として表示・通信する。未設定・空欄・旧設定では名前を一度だけ使う。同一性は account id と接続 id のままである。アカウント表示は呼称と名前の二段、会話の話し手欄は両方を表示する。宛先規則は [部屋](1-room.md#ニックネームと元の名前383)、保存と稼働席は [アカウント](3-accounts.md#ニックネーム383) に定義する。 + +## 投稿結果の時刻と利用量(#385) + +`say_to_room` は自己呼称に加え、タイムゾーンつき現在時刻と本人の5時間・週間枠(使用率とリセット時刻)、コンテキスト使用率、各データの受信時刻を返す。本文や部屋のログへ流さない。未取得は不明とし、最後の報告を現在値と断定しない。投稿成功・拒否・失敗と last_seen の契約は保つ。利用量の追加読み出しは最大1.5秒で打ち切り、不調でも投稿結果を変更しない。 diff --git a/docs/1-room.md b/docs/1-room.md index 9b64f92..66f4424 100644 --- a/docs/1-room.md +++ b/docs/1-room.md @@ -526,3 +526,9 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre 名簿の name・投稿の speaker・札の from は宛先呼称である。任意の full_name は投稿時の元の名前のスナップショットで、表示と AI の認識用であり宛先に使わない。画面・端末の札・MCP の履歴と未読の返却がこの対応を保持する。to は従来どおり呼称文字列を保存し、後の改名で古い投稿の宛先や話し手を変更しない。既存ログに full_name がなくても読め、名前を一度だけ表示する。 稼働席の改名は全 topic の名簿へ反映し、接続と floor の既読位置は維持する。再接続は保存設定の呼称を採用する。sidecar は identity フレームで現在の呼称と元の名前を受け、initialize は標準 MCP SDK の処理で起動時の呼称・元の名前を示す。tools/list・各 tool 結果に現在の自己情報を出す。各端末への札の you / your_full_name は受け手の現在の対応を示し、起動時の自己呼称を更新する。from / full_name は話し手の対応であり、you は受け手である。制限中に保持する投稿では話し手の両表記を保持済み speaker 文字列にまとめる。追加情報は任意項目で、未知フィールド・フレームを無視する既存互換方針を使う。 + +## 投稿結果の利用量フィードバック(#385) + +`say_to_room` の返り値には自己情報に続いて現在時刻(現地時刻と UTC 差)、本人の5時間・週間枠の使用率とリセット時刻、コンテキスト使用率を添える。`my_usage` と同じ読み出し・整形を使い、各値の受信時刻と経過時間を示して最後の報告と明記する。窓・リセット・context の未取得は `not reported`、読み出し不調は `unknown` とする。context は有効な Claude status 報告か Codex の token_count の info から記録し、画面への再送では受信時刻を進めない。context は新しい起動時に捨て、前のセッションの値を引き継がない。枠の値は従来どおり受信時刻つきで保持する。 + +投稿結果の判定後に利用量を読み、追加の待ちは最大1.5秒とする。成功・拒否・失敗・未確認の結果と last_seen、保持する下書きは変えない。利用量の取得失敗はその追記だけに表し、投稿の isError を変えない。読み出しは本人の接続だけを対象とし、本文・床・ログには追加しない。現在時刻は各呼び出しの返却時に計算し直す。 diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index 5c839bb..ba56d1e 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -334,6 +334,8 @@ interface UsageResultFrame { /** Absent when the window was not reported. */ five_hour?: UsageWindow; seven_day?: UsageWindow; + context?: number; + context_received_at?: number; /** Unix seconds the app received the figures at; absent when none have * arrived for this seat yet. */ received_at?: number; @@ -721,7 +723,20 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => { content: [{ type: "text", text: `Delivered to the room. message_id: ${messageId}` }], }; })(); - return { ...result, content: [...result.content, { type: "text" as const, text: identityText() }] }; + let feedback = identityText(); + if (request.params.name === "say_to_room") { + let usageText: string; + try { + const usage = await readUsage(1500); + usageText = usage.isError + ? `Usage unavailable: ${usage.content[0].text}; 5h, weekly, context: unknown.` + : usage.content[0].text; + } catch { + usageText = "Usage unavailable; 5h, weekly, context: unknown."; + } + feedback += `\nnow ${localMinute(Date.now() / 1000, true)}\n${usageText}`; + } + return { ...result, content: [...result.content, { type: "text" as const, text: feedback }] }; }); /** @@ -825,12 +840,12 @@ async function readHistory(args: Record | undefined): Promise<{ * Failures are `isError`, as a pull's are. "None received yet" is not one: the * room answered, and that is its answer. */ -async function readUsage(): Promise<{ +async function readUsage(timeoutMs = POST_RESULT_TIMEOUT): Promise<{ content: { type: "text"; text: string }[]; isError?: boolean; }> { const requestId = randomUUID(); - const answered = awaitUsageResult(requestId); + const answered = awaitUsageResult(requestId, timeoutMs); const sent = sendToRoom({ type: "usage", request_id: requestId }); if (!sent) { @@ -868,34 +883,35 @@ async function readUsage(): Promise<{ * are local with the UTC offset, as the room's labels write them. */ function describeUsage(result: UsageResultFrame, nowMs: number): string { - if (typeof result.received_at !== "number") { - return "No usage figures received for this seat yet."; - } + const age = (seconds: number) => `${localMinute(seconds)} (${Math.max(0, Math.round((nowMs / 1000 - seconds) / 60))} min ago)`; const window = (label: string, one: UsageWindow | undefined): string => { - if (typeof one?.used_percentage !== "number") return `${label}: not reported`; + if (typeof one?.used_percentage !== "number" || !Number.isFinite(one.used_percentage)) return `${label}: not reported`; const used = Math.round(one.used_percentage * 10) / 10; - const reset = typeof one.resets_at === "number" - ? `, resets ${localMinute(one.resets_at)}` - : ""; - return `${label}: ${used}% used${reset}`; + const reset = typeof one.resets_at === "number" && Number.isFinite(one.resets_at) + ? localMinute(one.resets_at) : "not reported"; + return `${label}: ${used}% used, resets ${reset}`; }; - const minutes = Math.max(0, Math.round((nowMs / 1000 - result.received_at) / 60)); return [ window("5h", result.five_hour), window("weekly", result.seven_day), - `received ${localMinute(result.received_at)} (${minutes} min ago)`, + typeof result.received_at === "number" && Number.isFinite(result.received_at) + ? `received ${age(result.received_at)}; last reported figures` + : "No usage figures received for this seat yet.", + typeof result.context === "number" && Number.isFinite(result.context) && typeof result.context_received_at === "number" + ? `context: ${Math.round(result.context * 10) / 10}% used, received ${age(result.context_received_at)}; last reported` + : "context: not reported", ].join("\n"); } /** Unix seconds as `YYYY-MM-DDTHH:MM+09:00`, in this machine's zone. */ -function localMinute(seconds: number): string { +function localMinute(seconds: number, includeSeconds = false): string { const at = new Date(seconds * 1000); const two = (n: number) => String(n).padStart(2, "0"); const offset = -at.getTimezoneOffset(); const sign = offset < 0 ? "-" : "+"; const abs = Math.abs(offset); return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())}` + - `T${two(at.getHours())}:${two(at.getMinutes())}` + + `T${two(at.getHours())}:${two(at.getMinutes())}${includeSeconds ? `:${two(at.getSeconds())}` : ""}` + `${sign}${two(Math.floor(abs / 60))}:${two(abs % 60)}`; } @@ -992,12 +1008,12 @@ const awaitingHistory = new Map v /** Reads of this seat's usage waiting for the room's answer, by request id. */ const awaitingUsage = new Map void>(); -function awaitUsageResult(requestId: string): Promise { +function awaitUsageResult(requestId: string, timeoutMs: number): Promise { return new Promise((resolve) => { const timer = setTimeout(() => { awaitingUsage.delete(requestId); resolve(null); - }, POST_RESULT_TIMEOUT); + }, timeoutMs); timer.unref?.(); awaitingUsage.set(requestId, (result) => { clearTimeout(timer); diff --git a/sidecar/test/round-trip.test.mjs b/sidecar/test/round-trip.test.mjs index bcb04a4..e391903 100644 --- a/sidecar/test/round-trip.test.mjs +++ b/sidecar/test/round-trip.test.mjs @@ -313,6 +313,8 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) { error: "this session has no account in the app, so it has no usage figures" }, ]; const usageFrames = []; + let explicitUsage = false; + let feedbackUsage = { five_hour: { used_percentage: 78, resets_at: 1791207909 }, seven_day: { used_percentage: 12 }, received_at: RECEIVED, context: 42, context_received_at: RECEIVED }; wss.on("connection", (socket) => { roomSocket = socket; @@ -322,6 +324,7 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) if (frame.type === "hello") helloSeen.resolve(frame); if (frame.type === "usage") { usageFrames.push(frame); + if (!explicitUsage && feedbackUsage === null) return; // Another read's answer first, carrying other figures: correlation is // by request_id, as a pull's is. socket.send( @@ -336,7 +339,7 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) JSON.stringify({ type: "usage_result", request_id: frame.request_id, - ...usageAnswers.shift(), + ...(explicitUsage ? usageAnswers.shift() : feedbackUsage), }), ); return; @@ -771,6 +774,7 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) assert.deepEqual(severalPost.to, ["Claude Lay", "Master"]); // ── the room refuses, and the refusal carries what was missed ────────────── + feedbackUsage = { error: "usage temporarily unavailable" }; answer = "refuse"; const refused = await request("tools/call", { name: "say_to_room", @@ -882,13 +886,18 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) "the pull must say that what it returns is not addressed to the reader", ); + assertContains(refused.result.content.at(-1).text, "Usage unavailable", "usage failure cannot erase refusal"); + feedbackUsage = { five_hour: { used_percentage: 78, resets_at: 1791207909 }, seven_day: { used_percentage: 12 }, received_at: RECEIVED, context: 42, context_received_at: RECEIVED }; + // ── this seat's own usage-limit figures (#364) ───────────────────────────── + explicitUsage = true; + const usageBaseline = usageFrames.length; const usageRead = await request("tools/call", { name: "my_usage", arguments: {} }); assert.ok(!usageRead.result.isError, `usage read failed: ${JSON.stringify(usageRead.result)}`); - assert.equal(usageFrames.length, 1, "one read produces exactly one frame"); + assert.equal(usageFrames.length - usageBaseline, 1, "one read produces exactly one frame"); // The frame names no seat: the room answers for the connection it came on. assert.deepEqual( - Object.keys(usageFrames[0]).sort(), + Object.keys(usageFrames[usageBaseline]).sort(), ["request_id", "type"], "a usage read must not name whose figures it wants", ); @@ -902,18 +911,21 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) assertContains(figures, "weekly: not reported", "a window not reported is said absent, not zero"); assert.match( figures, - /^received \d{4}-\d{2}-\d{2}T\d{2}:\d{2}[+-]\d{2}:\d{2} \(12 min ago\)$/m, + /^received \d{4}-\d{2}-\d{2}T\d{2}:\d{2}[+-]\d{2}:\d{2} \(12 min ago\); last reported figures$/m, `when the figures arrived, and how long ago\n${figures}`, ); const noneYet = await request("tools/call", { name: "my_usage", arguments: {} }); assert.ok(!noneYet.result.isError, "no figures yet is the room's answer, not a failure"); - assert.equal(noneYet.result.content[0].text, "No usage figures received for this seat yet."); + assertContains(noneYet.result.content[0].text, "No usage figures received for this seat yet.", "absence remains explicit"); + assertContains(noneYet.result.content[0].text, "context: not reported", "missing context is not zero"); const noSeat = await request("tools/call", { name: "my_usage", arguments: {} }); assert.ok(noSeat.result.isError, "a room that cannot say whose figures these are must read as a failure"); assertContains(noSeat.result.content[0].text, "Not read: this session has no account", "the reason is passed on"); + explicitUsage = false; + // ── the refused draft is held, and re-sent by leaving content out (#268) ─── // Still the draft refused above. It goes through the floor like any post — // with the watermark this call declares — and goes out as it was. @@ -923,6 +935,12 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) arguments: { last_seen: "m-10" }, }); assert.ok(!resent.result.isError, `re-send failed: ${JSON.stringify(resent.result)}`); + const feedback = resent.result.content.at(-1).text; + assert.match(feedback, /now \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}/); + assertContains(feedback, "5h: 78% used", "own limit feedback"); + assertContains(feedback, "weekly: 12% used, resets not reported", "reset absence is explicit"); + assertContains(feedback, "context: 42% used, received", "context has its receipt time"); + assertContains(feedback, "last reported", "past figures are not current claims"); const resentPost = await nextPost(4); assert.equal(resentPost.content, "私も答えます", "the held draft goes out unchanged"); assert.equal(resentPost.last_seen, "m-10", "a re-send declares its own watermark"); @@ -935,6 +953,8 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) // Delivered, so nothing is held any more: leaving content out is now a // missing body, said as such, and nothing goes to the room. + feedbackUsage = null; + const feedbackStarted = Date.now(); const nothingHeld = await request("tools/call", { name: "say_to_room", arguments: { last_seen: "m-10" }, @@ -948,6 +968,9 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) assert.equal(postFrames.length, 5, "a call that sends nothing puts nothing on the floor"); // A held draft keeps its addressees, and the newest refusal replaces it. + assertContains(nothingHeld.result.content.at(-1).text, "Usage unavailable", "timeout affects feedback only"); + assert.ok(Date.now() - feedbackStarted < 5000, "feedback timeout is bounded"); + feedbackUsage = { context: 55, context_received_at: RECEIVED }; answer = "refuse"; await request("tools/call", { name: "say_to_room", @@ -958,7 +981,11 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) arguments: { content: "マスター宛の下書き", to: "Master", last_seen: "m-1" }, }); answer = "deliver"; - await request("tools/call", { name: "say_to_room", arguments: { last_seen: "m-10" } }); + feedbackUsage = null; + const deliveredWithoutUsage = await request("tools/call", { name: "say_to_room", arguments: { last_seen: "m-10" } }); + assert.ok(!deliveredWithoutUsage.result.isError, "usage timeout cannot make a delivered post fail"); + assertContains(deliveredWithoutUsage.result.content.at(-1).text, "Usage unavailable", "delivered result survives absent usage"); + feedbackUsage = { context: 55, context_received_at: RECEIVED }; const addressedPost = await nextPost(7); assert.equal(addressedPost.content, "マスター宛の下書き", "the newest refused draft is the one held"); assert.deepEqual(addressedPost.to, ["Master"], "a held draft keeps who it was for"); @@ -981,6 +1008,7 @@ test("say_to_room reaches the room, and the room pushes nothing back", async (t) name: "say_to_room", arguments: { last_seen: "m-10" }, }); + assertContains(clearedAgain.result.content.at(-1).text, "context: 55%", "a later call fetches new figures"); assert.ok(clearedAgain.result.isError, "a delivery clears the held draft, whatever was sent"); roomSocket.send(JSON.stringify({type: "identity", nickname: "Luna", name: "Codex Luna"})); diff --git a/src-tauri/src/codex_status.rs b/src-tauri/src/codex_status.rs index fef37bb..387e2d3 100644 --- a/src-tauri/src/codex_status.rs +++ b/src-tauri/src/codex_status.rs @@ -121,6 +121,9 @@ fn follow(limiter: &Limiter, home: &std::path::Path, resumed: Option<(PathBuf, u }; // When the figures were received, changed or not (#364). if let Some(tail) = tail.as_deref_mut() { + if tail.take_context_read() { + app.state::().record_context(topic_id, account_id, tail.status.context); + } if tail.take_limits_read() { limiter.received_usage(&tail.status); } diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index c43fb4d..06df7a1 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -1495,8 +1495,18 @@ impl UsageBook { self.0.lock().record(topic, account, usage); } + pub fn begin_session(&self, topic: &str, account: &str) { + self.0.lock().begin_session(topic, account); + } + + pub fn record_context(&self, topic: &str, account: &str, context: Option) { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64).unwrap_or_default(); + self.0.lock().record_context(topic, account, context, now); + } + fn answer(&self, request_id: &str, topic: &str, account: &str) -> serde_json::Value { - mcp_config::usage::answer(request_id, self.0.lock().get(topic, account)) + self.0.lock().answer(request_id, topic, account) } fn forget_topic(&self, topic: &str) { @@ -1788,8 +1798,12 @@ async fn read_hook( // Stamped on arrival, and kept only for a report the seat's own // launch sent — the one the panel takes (#364). let usage = stats.as_ref().map(SessionStats::usage); + let context = stats.as_ref().map(|stats| stats.context); if app.state::().report(app, &room_id, &account_id, claude_nonce.as_deref(), &body, stats) { + if let Some(context) = context { + app.state::().record_context(&room_id, &account_id, context); + } if let Some(usage) = usage { app.state::().record(&room_id, &account_id, usage); } diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 49f3464..8d863a4 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -1615,6 +1615,8 @@ pub fn start_session( ) })?; + app.state::().begin_session(&topic.topic_id, &account.id); + // Where a Codex seat's rollout lives, and — on a resume — how long the file // already was before this launch. That length is where the reading starts, // so the panel shows what this run reports and not the last run's values