Compare commits
3 Commits
4b9b4f1450
...
8b5f75ecc4
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b5f75ecc4 | |||
| be22c65af3 | |||
| d9f55185f0 |
|
|
@ -1,5 +1,10 @@
|
|||
全体設計が概ね固まり、随所の細かい仕様を詰めながら実装を進めている。
|
||||
|
||||
## このシステムに置ける設計要旨
|
||||
|
||||
- プロンプトはすべて resources/promptsに集約している。管理効率の工場と同時に、ユーザーがオーバーライドする形式でもある。
|
||||
- E2E(実プロセスをスポーンさせてのテスト)は未設計。
|
||||
|
||||
---
|
||||
|
||||
Gitは基本的にすべてユーザーが操作している。書き込みが必要な操作は明示的に許可されない限り行わないこと
|
||||
|
|
|
|||
2
TODO.md
2
TODO.md
|
|
@ -14,6 +14,7 @@
|
|||
- [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.md)
|
||||
- [ ] Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
|
||||
- [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
|
||||
- [ ] auto-kick 由来ターンが描画されない → [tickets/tui-pod-event-render.md](tickets/tui-pod-event-render.md)
|
||||
- [ ] サブミット入力
|
||||
- [ ] FileRef リゾルバ → [tickets/submit-file-ref-resolver.md](tickets/submit-file-ref-resolver.md)
|
||||
- [ ] Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和 → [tickets/manifest-output-upload-limits.md](tickets/manifest-output-upload-limits.md)
|
||||
|
|
@ -21,5 +22,6 @@
|
|||
- [ ] 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md)
|
||||
- [ ] Phase 2 累積入力トークン上限の撤去 → [tickets/memory-consolidation-drop-input-cap.md](tickets/memory-consolidation-drop-input-cap.md)
|
||||
- [ ] セッション内 TODO ツール(注意機構付き) → [tickets/session-todo.md](tickets/session-todo.md)
|
||||
- [ ] セッションメトリクス: Extension 経由の汎用計測レーン(最初の利用者は Prune) → [tickets/session-metrics.md](tickets/session-metrics.md)
|
||||
- ワークスペースのメモリーをLintするヘッドレスCLI
|
||||
- system-reminder 注入機構の汎用化(2件目の利用者が出た時に検討。タグ形式と「履歴を汚さない」原則は session-todo で先行確立)
|
||||
|
|
|
|||
|
|
@ -619,20 +619,14 @@ impl App {
|
|||
pub fn delete_char_after(&mut self) {
|
||||
self.input.delete_after();
|
||||
}
|
||||
pub fn delete_word_before(&mut self) {
|
||||
self.input.delete_word_before();
|
||||
}
|
||||
pub fn move_cursor_left(&mut self) {
|
||||
self.input.move_left();
|
||||
}
|
||||
pub fn move_cursor_right(&mut self) {
|
||||
self.input.move_right();
|
||||
}
|
||||
pub fn move_cursor_word_left(&mut self) {
|
||||
self.input.move_word_left();
|
||||
}
|
||||
pub fn move_cursor_word_right(&mut self) {
|
||||
self.input.move_word_right();
|
||||
pub fn move_cursor_start(&mut self) {
|
||||
self.input.move_start();
|
||||
}
|
||||
pub fn move_cursor_home(&mut self) {
|
||||
self.input.move_home();
|
||||
|
|
|
|||
|
|
@ -345,6 +345,10 @@ impl InputBuffer {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn move_start(&mut self) {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
pub fn move_home(&mut self) {
|
||||
while self.cursor > 0 {
|
||||
if matches!(self.atoms[self.cursor - 1], Atom::Char('\n')) {
|
||||
|
|
@ -921,6 +925,14 @@ mod word_motion_tests {
|
|||
assert_eq!(cursor(&buf), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_start_lands_at_beginning_of_buffer() {
|
||||
let mut buf = buf_from("foo\nbar");
|
||||
assert_eq!(cursor(&buf), 7);
|
||||
buf.move_start();
|
||||
assert_eq!(cursor(&buf), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_from_start_lands_after_first_word() {
|
||||
let mut buf = buf_from("foo bar baz");
|
||||
|
|
|
|||
|
|
@ -376,16 +376,65 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||
|
||||
// Scroll / navigation (history view).
|
||||
match key.code {
|
||||
// Modifier-key bindings.
|
||||
if let Some(method) = match key.code {
|
||||
KeyCode::Up if shift => {
|
||||
app.scroll.scroll_up(1);
|
||||
return None;
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::Down if shift => {
|
||||
app.scroll.scroll_down(1);
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::Home if ctrl => {
|
||||
app.scroll.to_top();
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::End if ctrl => {
|
||||
app.scroll.to_bottom();
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::Char('[') if ctrl => {
|
||||
app.scroll.jump_prev_turn();
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::Char(']') if ctrl => {
|
||||
app.scroll.jump_next_turn();
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::Char('o') if ctrl => {
|
||||
app.mode = app.mode.cycle();
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::Char('a') if ctrl => {
|
||||
app.move_cursor_start();
|
||||
Some(app.refresh_completion())
|
||||
}
|
||||
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
||||
KeyCode::Char('x') if ctrl => Some(if app.running {
|
||||
Some(Method::Cancel)
|
||||
} else {
|
||||
app.push_error("Nothing to cancel (Pod is not running).");
|
||||
None
|
||||
}),
|
||||
KeyCode::Char('d') if ctrl => Some(handle_shutdown(app)),
|
||||
KeyCode::Enter if alt => {
|
||||
app.insert_newline();
|
||||
Some(app.refresh_completion())
|
||||
}
|
||||
_ => None,
|
||||
} {
|
||||
return method;
|
||||
}
|
||||
|
||||
// Unbound Ctrl+Char keys are ignored before the text-input path so
|
||||
// holding Ctrl while typing never inserts control characters.
|
||||
if ctrl && matches!(key.code, KeyCode::Char(_)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Scroll / navigation (history view).
|
||||
match key.code {
|
||||
KeyCode::PageUp => {
|
||||
app.scroll.page_up();
|
||||
return None;
|
||||
|
|
@ -394,26 +443,6 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
app.scroll.page_down();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Home if ctrl => {
|
||||
app.scroll.to_top();
|
||||
return None;
|
||||
}
|
||||
KeyCode::End if ctrl => {
|
||||
app.scroll.to_bottom();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Char('[') if ctrl => {
|
||||
app.scroll.jump_prev_turn();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Char(']') if ctrl => {
|
||||
app.scroll.jump_next_turn();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Char('o') if ctrl => {
|
||||
app.mode = app.mode.cycle();
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
|
@ -463,31 +492,13 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('c') if ctrl => handle_pause_or_quit(app),
|
||||
KeyCode::Char('x') if ctrl => {
|
||||
if app.running {
|
||||
Some(Method::Cancel)
|
||||
} else {
|
||||
app.push_error("Nothing to cancel (Pod is not running).");
|
||||
None
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') if ctrl => handle_shutdown(app),
|
||||
KeyCode::Esc => {
|
||||
// Close the popup if it's still showing (covers the
|
||||
// request-in-flight case where `is_active()` was false).
|
||||
app.cancel_completion();
|
||||
None
|
||||
}
|
||||
KeyCode::Enter if alt => {
|
||||
app.insert_newline();
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Enter => app.submit_input(),
|
||||
KeyCode::Backspace if ctrl => {
|
||||
app.delete_word_before();
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.delete_char_before();
|
||||
app.refresh_completion()
|
||||
|
|
@ -496,18 +507,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
app.delete_char_after();
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Left if ctrl => {
|
||||
app.move_cursor_word_left();
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Left => {
|
||||
app.move_cursor_left();
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Right if ctrl => {
|
||||
app.move_cursor_word_right();
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Right => {
|
||||
app.move_cursor_right();
|
||||
app.refresh_completion()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
|
||||
| キー | 動作 |
|
||||
|---|---|
|
||||
| 文字キー | カーソル位置に挿入 |
|
||||
| 文字キー | カーソル位置に挿入(未割り当ての `Ctrl`+文字キーは無視) |
|
||||
| `Ctrl-A` | 入力欄全体の先頭へ |
|
||||
| `Backspace` | カーソル直前を削除(ペーストプレースホルダは 1 回で全削除) |
|
||||
| `Delete` | カーソル直後を削除(同上) |
|
||||
| `Left` / `Right` | カーソル移動 |
|
||||
|
|
|
|||
74
tickets/session-metrics.md
Normal file
74
tickets/session-metrics.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# セッションメトリクス: Extension 経由の汎用計測レーン
|
||||
|
||||
## 背景
|
||||
|
||||
セッション中の挙動を後から定量評価したい需要が増えている。直近の動機は Prune projection の効果測定(どこでどれくらいの頻度で発火したか、KV キャッシュ無効化のコストに対して回収トークンがどれだけあったか)だが、Compact 起動条件、Hook の実行時間、ツール呼び出しのリトライ回数など、同種の「セッション中に積み上げて後で引きたい」値は今後も発生する。
|
||||
|
||||
現状の session-log は `UserInput` / `AssistantItems` / `LlmUsage` といった状態遷移を典型化した variant のみで、ad hoc な計測値の置き場所がない。一方 `LogEntry::Extension { domain, payload }` という汎用エスケープハッチが既に用意されており、ここに乗せれば hash chain・replay 経路を流用できる。
|
||||
|
||||
## 方針
|
||||
|
||||
- `LogEntry::Extension` の `domain = "metrics"` 名前空間として実装する。session-store の型は触らない
|
||||
- メトリクス型は `name + dimensions(sparse map) + value(Option) + correlation_id(Option)` 程度の最小スキーマ。値が測れない次元は `None` で明示し、Prometheus 的な厳格な label set は持たない
|
||||
- 「後から埋まる値」(例: prune 発火直後の LLM 呼び出しで観測される `cache_read_tokens`)は前 entry に書き戻さず、`correlation_id` を共有する別 metric として流す。集計は読み手側で join
|
||||
- 集計 / 可視化 API は本チケットでは作らない。session-log を読めば取り出せる、までを到達点とする
|
||||
|
||||
## 要件
|
||||
|
||||
### メトリクス型
|
||||
|
||||
- 専用 crate(または既存の適切な配置)に定義。`serde` で JSON ラウンドトリップ可能
|
||||
- 必須: `name`(`namespace.metric` 形式の文字列、例: `prune.fire`)、`ts`(u64 epoch ms)
|
||||
- 任意: `dimensions: BTreeMap<String, String>`、`value: Option<f64>`、`correlation_id: Option<String>`
|
||||
- 「unknown」は対応フィールドを `None` にすることで表現。schema レベルで dimension の網羅性は要求しない
|
||||
|
||||
### 書き込み経路
|
||||
|
||||
- `Pod` から呼べる薄いヘルパー(例: `Pod::record_metric(&self, metric)`) を session-store に追加し、`LogEntry::Extension { domain: "metrics", payload: serde_json::to_value(metric) }` として append する
|
||||
- 既存の `append_entry` フローを踏襲し、hash chain に乗る
|
||||
- 書き込み失敗(store IO エラー)はメトリクス側で握りつぶす。本体処理を阻害しない
|
||||
|
||||
### 読み出し経路
|
||||
|
||||
- replay 時、`RestoredState.extensions` に `("metrics", payload)` として既に積まれる(既存挙動)
|
||||
- メトリクスドメイン側で payload を `Vec<Metric>` に fold するヘルパーを提供
|
||||
- session-store のテストハーネスから「特定セッションの metric 列を取り出す」サンプルが書ける状態にする
|
||||
|
||||
### 最初の利用者: Prune projection
|
||||
|
||||
本チケットの完了は、最低 1 つの実利用者が乗っていることを条件とする。Prune を最初の利用者として組み込む:
|
||||
|
||||
- `pod::compact::prune` の `attach_prune` 経路で、projection 評価のたびに以下を発行
|
||||
- 発火時: `name = "prune.fire"`, `dimensions = { border_turn, candidate_count }`, `value = estimated_savings`, `correlation_id = <次の LLM 呼び出しと紐付ける ID>`
|
||||
- スキップ時: `name = "prune.skip"`, `dimensions = { reason }`(`below_min_savings` / `no_candidates` 等)
|
||||
- 直後の LLM リクエストで `LlmUsage` が記録される際、同じ `correlation_id` を持つ補助 metric `prune.post_request` を併発し、`cache_read_tokens` / `cache_write_tokens` を value/dimension として記録
|
||||
- `correlation_id` の生成・伝搬経路は実装側で決定。既存の request-id 系があれば再利用
|
||||
|
||||
### Resume 互換
|
||||
|
||||
- 旧セッション(metric entry を持たないログ)の replay は何も変えない。`extensions` に metrics domain が無いだけ
|
||||
- payload schema が将来変わった場合、deserialize 失敗した metric は無視してよい(fold ヘルパー側で `serde_json::from_value` の Err を skip)
|
||||
|
||||
## 完了条件
|
||||
|
||||
- メトリクス型と書き込み / 読み出しヘルパーが定義され、unit test がある
|
||||
- Prune projection から `prune.fire` / `prune.skip` / `prune.post_request` が session-log に乗る
|
||||
- 既存セッションログの replay が壊れない(後方互換)
|
||||
- セッションログから prune metric 列を取り出すテストが通る
|
||||
- correlation_id で prune 発火と直後の LLM 呼び出しの cache 値が join できることを test で示す
|
||||
|
||||
## 範囲外
|
||||
|
||||
- 集計 / 可視化ツール(CLI / TUI)。後続で別途
|
||||
- ワークスペースまたぎのメトリクス集約(複数セッション横断分析)
|
||||
- リアルタイム購読 API(Watcher 経由の stream 配信)
|
||||
- session-log 以外の sink(jsonl 別系統、外部時系列 DB 等)
|
||||
- Prune 以外のメトリクス利用者の追加(Compact / Hook 等は別チケット)
|
||||
- メトリクス保存量の自動圧縮 / 退避
|
||||
|
||||
## 参照
|
||||
|
||||
- 設計指針: `CLAUDE.md`(最小の構造化 / 概念の追加は不在が問題になってから)
|
||||
- `crates/session-store/src/session_log.rs`(`LogEntry::Extension` と `RestoredState.extensions` の既存仕様)
|
||||
- `crates/llm-worker/src/usage_record.rs`、`crates/llm-worker/src/llm_client/event.rs`(cache_read / cache_write の取得経路)
|
||||
- `crates/pod/src/compact/prune.rs`、`crates/llm-worker/src/prune.rs`(最初の利用者の挿入点)
|
||||
20
tickets/tui-pod-event-render.md
Normal file
20
tickets/tui-pod-event-render.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# TUI で auto-kick 由来のターンが表示されない
|
||||
|
||||
## 背景
|
||||
|
||||
Pod が `Method::PodEvent::TurnEnded` などを socket 経由で受信すると、controller は notification を notify buffer に積み、Idle なら `pod.run_for_notification()` で新しいターンを起動する(`crates/pod/src/controller.rs:611-687`)。このターンの assistant 出力 (`Event::TurnStart` / `TextDelta` / `TurnEnd` 等) は通常通り broadcast Event として全クライアント(TUI 含む)に配信されるはず。
|
||||
|
||||
## 問題
|
||||
|
||||
socat で稼働中の codex-oauth pod の socket に `Method::PodEvent::TurnEnded` を 1 行流したところ、socat 側の subscribe には turn が完全に流れてきた(thinking_delta / text_done / turn_end 取得済み)が、同じ pod を起動している TUI 画面には新ターンが描画されなかった。
|
||||
|
||||
`Method::Run` 経由の通常ターンは TUI に表示されるので、broadcast 配信そのものは生きている。auto-kick 由来のターン(user_message を伴わない turn)に固有の表示パスで落ちている可能性が高い。
|
||||
|
||||
## 要件
|
||||
|
||||
- auto-kick で起動したターン(user 入力を伴わないターン)も、user 由来ターンと同様に TUI 履歴に表示される。
|
||||
- turn header 等の見た目で「通知由来である」ことを示す表記を入れるかは別議論。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- 親 pod が PodEvent を受信して auto-kick した際、TUI 上で thinking / assistant text / turn_end が user 由来ターンと同様に表示される。
|
||||
Loading…
Reference in New Issue
Block a user