M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
//! Strongly-typed notification metadata.
|
||||
//!
|
||||
//! Parses the `_meta` JSON from `SessionNotification` into a struct with
|
||||
//! typed fields. All fields are `Option` — gracefully degrades when
|
||||
//! grok-shell hasn't been updated or meta is absent.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Parsed fields from `SessionNotification._meta`.
|
||||
///
|
||||
/// Extracted once in [`acp_handler`](super::super::app::acp_handler) and passed
|
||||
/// downstream to the tracker and agent state.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationMeta {
|
||||
/// Accumulated token count across the session (`totalTokens`).
|
||||
pub total_tokens: Option<u64>,
|
||||
/// UTC ms when this notification was sent (`agentTimestampMs`).
|
||||
pub agent_timestamp_ms: Option<i64>,
|
||||
/// UTC ms when the current LLM streaming response started (`streamStartMs`).
|
||||
/// Resets each tool-use loop iteration.
|
||||
pub stream_start_ms: Option<i64>,
|
||||
/// UTC ms when the current turn started (`turnStartMs`).
|
||||
/// Constant for the entire turn.
|
||||
pub turn_start_ms: Option<i64>,
|
||||
/// Stable id for the prompt this notification belongs to (`promptId`).
|
||||
/// The client passes a UUID in `PromptRequest._meta.promptId`; the agent
|
||||
/// echoes it on every notification it emits while processing that
|
||||
/// prompt. Used to drop chunks for cancelled / rewound turns.
|
||||
pub prompt_id: Option<String>,
|
||||
/// Whether this notification is historical replay from `session/load`.
|
||||
pub is_replay: bool,
|
||||
/// Raw `eventId` string (`"{sessionId}-{counter}"`). Tracked per session
|
||||
/// as the reconnect cursor (`_meta.cursor` on `session/load`): the agent
|
||||
/// resolves it by exact string match against persisted lines, so the full
|
||||
/// id is kept — the numeric suffix alone is ambiguous across the
|
||||
/// non-monotonic counter runs of a multi-resume history.
|
||||
pub event_id: Option<String>,
|
||||
/// Monotonic per-process sequence parsed from `eventId`
|
||||
/// (`"{sessionId}-{counter}"`, see `kigi-shell util::event_id`). The
|
||||
/// agent stamps the SAME `eventId` on the live emission and on the persisted
|
||||
/// line that is later replayed, so a client can dedup an event it receives
|
||||
/// twice (replay/live overlap, a re-emit after the reconnect gate, or
|
||||
/// duplicate routing). Per-session events arrive in increasing order, so the
|
||||
/// pager keeps a highwater and drops anything `<=` it. `None` when the agent
|
||||
/// didn't stamp an `eventId` (older shell) — such updates always apply.
|
||||
pub event_seq: Option<u64>,
|
||||
}
|
||||
|
||||
/// Serializable counterpart of the replay stamp the agent injects on
|
||||
/// replayed notifications (`_meta.isReplay`, stamped by kigi-shell's
|
||||
/// `forward_raw_replay_line` during `session/load`).
|
||||
///
|
||||
/// [`NotificationMeta::from_json`] is the parse side; this is the build
|
||||
/// side, so code that constructs a replay-stamped `_meta` (test fixtures,
|
||||
/// playgrounds) shares the wire key with the parser instead of hand-writing
|
||||
/// `json!` literals.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReplayMetaStamp {
|
||||
pub is_replay: bool,
|
||||
}
|
||||
|
||||
impl ReplayMetaStamp {
|
||||
/// `_meta` value for a replayed (`session/load`) notification.
|
||||
pub fn replayed() -> serde_json::Value {
|
||||
serde_json::to_value(Self { is_replay: true }).expect("serialize replay meta stamp")
|
||||
}
|
||||
}
|
||||
|
||||
/// User-prompt content-block `_meta` keys (`TextContent.meta`), shared by the
|
||||
/// producers (`dispatch/queue.rs` drain, `effects.rs` prompt send) and the
|
||||
/// replay consumer (`acp/tracker.rs` `handle_user_message`) so the wire keys
|
||||
/// cannot drift. Tests keep raw literals — they pin the wire values.
|
||||
pub mod user_prompt_meta {
|
||||
/// Clean display text shown in scrollback instead of the wire text.
|
||||
pub const DISPLAY_TEXT: &str = "displayText";
|
||||
/// Render the display text as a skill invocation (teal leading token).
|
||||
pub const DISPLAY_AS_SKILL: &str = "displayAsSkill";
|
||||
/// Render the display text as a scheduled (cron) prompt.
|
||||
pub const DISPLAY_AS_CRON: &str = "displayAsCron";
|
||||
/// `[[start, end], …]` byte ranges of recognized slash tokens into the
|
||||
/// block's `text`; only meaningful when that text is displayed verbatim
|
||||
/// (never stamped alongside `displayText`).
|
||||
pub const SKILL_TOKEN_RANGES: &str = "skillTokenRanges";
|
||||
}
|
||||
|
||||
/// `UserMessageChunk` / `ContentChunk._meta` keys stamped by the shell and
|
||||
/// read by the pager (live and on replay).
|
||||
pub mod user_message_chunk_meta {
|
||||
/// Prompt index for rewind / attribution.
|
||||
pub const PROMPT_INDEX: &str = "promptIndex";
|
||||
/// When true, the chunk must not become a scrollback user prompt
|
||||
/// ([`kigi_shell::session::PromptOrigin::hide_user_echo_from_scrollback`]).
|
||||
pub const HIDE_FROM_SCROLLBACK: &str = "hideFromScrollback";
|
||||
}
|
||||
|
||||
impl NotificationMeta {
|
||||
/// Parse from the `_meta` JSON map on a `SessionNotification`.
|
||||
pub fn from_json(meta: Option<&serde_json::Map<String, serde_json::Value>>) -> Self {
|
||||
let Some(m) = meta else {
|
||||
return Self::default();
|
||||
};
|
||||
let event_id = m
|
||||
.get("eventId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
// `eventId` is `"{sessionId}-{counter}"`; the counter is the part
|
||||
// after the LAST '-' (session ids themselves contain '-').
|
||||
let event_seq = event_id
|
||||
.as_deref()
|
||||
.and_then(|s| s.rsplit('-').next())
|
||||
.and_then(|c| c.parse::<u64>().ok());
|
||||
Self {
|
||||
total_tokens: m.get("totalTokens").and_then(|v| v.as_u64()),
|
||||
agent_timestamp_ms: m.get("agentTimestampMs").and_then(|v| v.as_i64()),
|
||||
stream_start_ms: m.get("streamStartMs").and_then(|v| v.as_i64()),
|
||||
turn_start_ms: m.get("turnStartMs").and_then(|v| v.as_i64()),
|
||||
prompt_id: m
|
||||
.get("promptId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
is_replay: m.get("isReplay").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
event_id,
|
||||
event_seq,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_full_meta() {
|
||||
let meta_json = json!({
|
||||
"totalTokens": 5000u64,
|
||||
"agentTimestampMs": 1700000000000i64,
|
||||
"streamStartMs": 1700000000000i64 - 3200,
|
||||
"turnStartMs": 1700000000000i64 - 5000,
|
||||
"eventId": "sess-1-7",
|
||||
});
|
||||
let map = meta_json.as_object().unwrap();
|
||||
let meta = NotificationMeta::from_json(Some(map));
|
||||
|
||||
assert_eq!(meta.total_tokens, Some(5000));
|
||||
assert_eq!(meta.agent_timestamp_ms, Some(1700000000000));
|
||||
assert_eq!(meta.stream_start_ms, Some(1700000000000 - 3200));
|
||||
assert_eq!(meta.turn_start_ms, Some(1700000000000 - 5000));
|
||||
assert!(!meta.is_replay);
|
||||
assert_eq!(meta.event_id.as_deref(), Some("sess-1-7"));
|
||||
assert_eq!(meta.event_seq, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_new_fields() {
|
||||
// Simulate old grok-shell that doesn't send streamStartMs/turnStartMs
|
||||
let meta_json = json!({
|
||||
"totalTokens": 1000u64,
|
||||
"agentTimestampMs": 1700000000000i64,
|
||||
});
|
||||
let map = meta_json.as_object().unwrap();
|
||||
let meta = NotificationMeta::from_json(Some(map));
|
||||
|
||||
assert_eq!(meta.total_tokens, Some(1000));
|
||||
assert_eq!(meta.agent_timestamp_ms, Some(1700000000000));
|
||||
assert_eq!(meta.stream_start_ms, None);
|
||||
assert_eq!(meta.turn_start_ms, None);
|
||||
assert!(!meta.is_replay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_replay_flag() {
|
||||
let meta_json = json!({
|
||||
"isReplay": true,
|
||||
});
|
||||
let map = meta_json.as_object().unwrap();
|
||||
let meta = NotificationMeta::from_json(Some(map));
|
||||
|
||||
assert!(meta.is_replay);
|
||||
}
|
||||
|
||||
/// The build side ([`ReplayMetaStamp::replayed`]) and the parse side
|
||||
/// ([`NotificationMeta::from_json`]) must agree on the wire key — a
|
||||
/// rename on either side breaks replay detection silently otherwise.
|
||||
#[test]
|
||||
fn replay_meta_stamp_round_trips_through_parser() {
|
||||
let stamp = ReplayMetaStamp::replayed();
|
||||
let map = stamp.as_object().unwrap();
|
||||
let meta = NotificationMeta::from_json(Some(map));
|
||||
assert!(meta.is_replay, "stamped meta must parse as a replay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_event_id_keeps_raw_string_and_seq() {
|
||||
let meta_json = json!({
|
||||
"eventId": "sess-ab-12-42",
|
||||
});
|
||||
let map = meta_json.as_object().unwrap();
|
||||
let meta = NotificationMeta::from_json(Some(map));
|
||||
|
||||
assert_eq!(meta.event_id.as_deref(), Some("sess-ab-12-42"));
|
||||
assert_eq!(meta.event_seq, Some(42));
|
||||
}
|
||||
|
||||
/// A non-numeric suffix yields no seq (dedup disabled) but the raw id is
|
||||
/// still kept for the reconnect cursor (string-matched agent-side).
|
||||
#[test]
|
||||
fn parse_event_id_without_numeric_suffix() {
|
||||
let meta_json = json!({
|
||||
"eventId": "weird-id-zzz",
|
||||
});
|
||||
let map = meta_json.as_object().unwrap();
|
||||
let meta = NotificationMeta::from_json(Some(map));
|
||||
|
||||
assert_eq!(meta.event_id.as_deref(), Some("weird-id-zzz"));
|
||||
assert_eq!(meta.event_seq, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_none_meta() {
|
||||
let meta = NotificationMeta::from_json(None);
|
||||
assert_eq!(meta.total_tokens, None);
|
||||
assert_eq!(meta.agent_timestamp_ms, None);
|
||||
assert_eq!(meta.stream_start_ms, None);
|
||||
assert_eq!(meta.turn_start_ms, None);
|
||||
assert!(!meta.is_replay);
|
||||
assert_eq!(meta.event_id, None);
|
||||
assert_eq!(meta.event_seq, None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user