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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crates/mcp-config/src/codex/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
43 changes: 43 additions & 0 deletions crates/mcp-config/src/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub struct Usage {
#[derive(Debug, Default)]
pub struct Book {
seats: HashMap<(String, String), Usage>,
contexts: HashMap<(String, String), (Option<f64>, i64)>,
}

impl Book {
Expand All @@ -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<f64>, 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);
}
}

Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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秒で打ち切り、不調でも投稿結果を変更しない。
6 changes: 6 additions & 0 deletions docs/1-room.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 を変えない。読み出しは本人の接続だけを対象とし、本文・床・ログには追加しない。現在時刻は各呼び出しの返却時に計算し直す。
50 changes: 33 additions & 17 deletions sidecar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }] };
});

/**
Expand Down Expand Up @@ -825,12 +840,12 @@ async function readHistory(args: Record<string, unknown> | 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) {
Expand Down Expand Up @@ -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)}`;
}

Expand Down Expand Up @@ -992,12 +1008,12 @@ const awaitingHistory = new Map<string, (result: HistoryResultFrame | null) => v
/** Reads of this seat's usage waiting for the room's answer, by request id. */
const awaitingUsage = new Map<string, (result: UsageResultFrame | null) => void>();

function awaitUsageResult(requestId: string): Promise<UsageResultFrame | null> {
function awaitUsageResult(requestId: string, timeoutMs: number): Promise<UsageResultFrame | null> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
awaitingUsage.delete(requestId);
resolve(null);
}, POST_RESULT_TIMEOUT);
}, timeoutMs);
timer.unref?.();
awaitingUsage.set(requestId, (result) => {
clearTimeout(timer);
Expand Down
Loading
Loading