Compare commits

...

24 Commits

Author SHA1 Message Date
f858f48c34
close: pod orchestration guidance 2026-06-01 10:24:59 +09:00
b7cbf05305
merge: pod orchestration guidance 2026-06-01 10:24:33 +09:00
d6e36a3268
review: approve pod orchestration guidance 2026-06-01 10:24:27 +09:00
6437580600
prompt: add pod orchestration guidance 2026-06-01 10:19:49 +09:00
5af9a5b1b6
ticket: refine pod orchestration guidance 2026-06-01 10:10:59 +09:00
623f45a254
close: prompt occupancy estimator 2026-06-01 10:10:17 +09:00
e51944f045
merge: prompt occupancy estimator 2026-06-01 10:09:30 +09:00
a40e45c90a
review: approve prompt occupancy estimator 2026-06-01 09:59:20 +09:00
2d4ffe0bce
ticket: supplement pod orchestration guidance 2026-06-01 09:57:09 +09:00
375d0216d1
fix: correct prompt occupancy extrapolation 2026-06-01 09:52:39 +09:00
3ea005822e
ticket: prompt occupancy token estimator 2026-06-01 09:41:22 +09:00
231ab3a4bf
close: memory prompt guidance 2026-06-01 07:52:47 +09:00
bdb52b1ec7
merge: memory prompt guidance 2026-06-01 07:51:39 +09:00
62d88d919d
review: approve memory prompt guidance 2026-06-01 07:51:36 +09:00
3a0c8e1597
memory: clean prompt helper warning 2026-06-01 07:50:27 +09:00
03256db913
memory: gate prompt guidance 2026-06-01 07:45:06 +09:00
ccddab6425
close: bash tool editing guidance 2026-06-01 07:36:41 +09:00
cff858ec23
ticket: add memory prompt thread files 2026-06-01 07:35:44 +09:00
6b48c4d3c5
ticket: memory prompt conditional lookup 2026-06-01 07:35:36 +09:00
681a37905c
close: local secret store 2026-06-01 07:23:54 +09:00
629159a29f
merge: local secret store 2026-06-01 07:21:26 +09:00
c9e48b320d
review: approve local secret store 2026-06-01 07:21:10 +09:00
7ddf74570a
secrets: polish key manager and docs 2026-06-01 07:19:28 +09:00
cc2c9a2973
secrets: add local key store 2026-06-01 07:07:39 +09:00
57 changed files with 2388 additions and 269 deletions

14
Cargo.lock generated
View File

@ -2473,6 +2473,7 @@ dependencies = [
"llm-worker",
"manifest",
"reqwest",
"secrets",
"serde",
"serde_json",
"serial_test",
@ -3047,6 +3048,17 @@ version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "secrets"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@ -3783,6 +3795,7 @@ dependencies = [
"markup5ever_rcdom",
"reqwest",
"schemars",
"secrets",
"serde",
"serde_json",
"sha2 0.11.0",
@ -3932,6 +3945,7 @@ dependencies = [
"protocol",
"pulldown-cmark",
"ratatui",
"secrets",
"serde",
"serde_json",
"session-store",

View File

@ -6,6 +6,7 @@ members = [
"crates/llm-worker",
"crates/llm-worker-macros",
"crates/session-store",
"crates/secrets",
"crates/manifest",
"crates/pod",
"crates/insomnia",
@ -41,6 +42,7 @@ protocol = { path = "crates/protocol" }
provider = { path = "crates/provider" }
session-metrics = { path = "crates/session-metrics" }
session-store = { path = "crates/session-store" }
secrets = { path = "crates/secrets" }
tools = { path = "crates/tools" }
tui = { path = "crates/tui" }

View File

@ -15,6 +15,7 @@ enum Mode {
MemoryLintHelp,
MemoryLint(LintCliOptions),
PodRuntime(Vec<String>),
Keys,
Tui(LaunchMode),
}
@ -58,6 +59,7 @@ async fn main() -> ExitCode {
}
},
Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("insomnia pod", args).await,
Mode::Keys => tui::keys::launch().await,
Mode::Tui(mode) => {
let runtime_command = match PodRuntimeCommand::resolve() {
Ok(command) => command,
@ -96,6 +98,12 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
match args[0].as_str() {
"--help" | "-h" => return Ok(Mode::Help),
"pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())),
"keys" => {
if args.len() != 1 {
return Err(ParseError("insomnia keys does not accept arguments".into()));
}
return Ok(Mode::Keys);
}
"memory" if args.get(1).map(String::as_str) == Some("lint") => {
let lint_args = &args[2..];
if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") {
@ -314,7 +322,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() {
println!(
"insomnia\n\nUsage:\n insomnia [OPTIONS] [POD_NAME]\n insomnia pod [POD_OPTIONS]\n insomnia memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --multi Open the multi-Pod dashboard\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Start a fresh Pod from a profile\n -h, --help Print help\n"
"insomnia\n\nUsage:\n insomnia [OPTIONS] [POD_NAME]\n insomnia keys\n insomnia pod [POD_OPTIONS]\n insomnia memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --multi Open the multi-Pod dashboard\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Start a fresh Pod from a profile\n -h, --help Print help\n"
);
}
@ -378,6 +386,14 @@ mod tests {
}
}
#[test]
fn parse_keys_subcommand() {
match parse_args_from(["keys"]).unwrap() {
Mode::Keys => {}
_ => panic!("expected Keys mode"),
}
}
#[test]
fn parse_literal_pod_name_still_available_with_flag() {
match parse_args_from(["--pod", "pod"]).unwrap() {

View File

@ -6,7 +6,8 @@
//! # 方針
//!
//! - ローカルトークナイザは持たない。実測値があればそれを採用し、
//! measurement 間はバイト数で按分、最新 measurement より先は最終 rate で外挿する
//! measurement 間はバイト数で按分、最新 measurement より先は測定済みの増分 rate
//! または byte/4 fallback で外挿する
//! - 推定の出どころは [`EstimateSource`] で呼び出し側に明示する。
//! 課金判断には使えないが、compact / prune / memory extract trigger 等の
//! 閾値判定には十分な精度
@ -119,17 +120,35 @@ pub fn tokens_at(
(Some(lo), None) => {
let lo_bytes = prefix[lo.history_len.min(cap)];
let at_bytes = prefix[index];
if lo_bytes == 0 || lo.input_total_tokens == 0 {
return TokenEstimate {
tokens: lo.input_total_tokens,
source: EstimateSource::Extrapolated,
};
}
let delta_bytes = at_bytes.saturating_sub(lo_bytes);
let delta_tokens =
(delta_bytes as u128 * lo.input_total_tokens as u128 / lo_bytes as u128) as u64;
let mut measured_span = None;
for pair in records.windows(2) {
let older = &pair[0];
let newer = &pair[1];
if newer.history_len > lo.history_len {
break;
}
let older_bytes = prefix[older.history_len.min(cap)];
let newer_bytes = prefix[newer.history_len.min(cap)];
let span_bytes = newer_bytes.saturating_sub(older_bytes);
let span_tokens = newer
.input_total_tokens
.saturating_sub(older.input_total_tokens);
if span_bytes > 0 && span_tokens > 0 {
measured_span = Some((span_tokens, span_bytes));
}
}
let delta_tokens = if let Some((span_tokens, span_bytes)) = measured_span {
(delta_bytes as u128 * span_tokens as u128 / span_bytes as u128) as u64
} else {
delta_bytes / 4
};
TokenEstimate {
tokens: lo.input_total_tokens + delta_tokens,
tokens: lo.input_total_tokens.saturating_add(delta_tokens),
source: EstimateSource::Extrapolated,
}
}
@ -214,6 +233,47 @@ mod tests {
assert!(est.tokens > 100);
}
#[test]
fn extrapolation_after_single_measurement_uses_byte_fallback_not_total_prompt_rate() {
let history = vec![msg("first"), msg(&"tool output ".repeat(400))];
let records = vec![record(1, 11_124)];
let prefix = prefix_bytes(&history);
let delta_bytes = prefix[2].saturating_sub(prefix[1]);
let est = total_tokens(&history, &records);
assert_eq!(est.source, EstimateSource::Extrapolated);
assert_eq!(est.tokens, 11_124 + delta_bytes / 4);
let old_projection =
11_124 + (delta_bytes as u128 * 11_124_u128 / prefix[1] as u128) as u64;
assert!(
old_projection > est.tokens.saturating_mul(10),
"old_projection={old_projection}, corrected={}",
est.tokens
);
}
#[test]
fn extrapolation_prefers_latest_measured_incremental_span_rate() {
let history = vec![
msg("first"),
msg(&"measured increment ".repeat(20)),
msg(&"unmeasured increment ".repeat(30)),
];
let records = vec![record(1, 10_000), record(2, 10_200)];
let prefix = prefix_bytes(&history);
let measured_bytes = prefix[2].saturating_sub(prefix[1]);
let delta_bytes = prefix[3].saturating_sub(prefix[2]);
let expected_delta = (delta_bytes as u128 * 200_u128 / measured_bytes as u128) as u64;
let est = total_tokens(&history, &records);
assert_eq!(est.source, EstimateSource::Extrapolated);
assert_eq!(est.tokens, 10_200 + expected_delta);
assert_ne!(est.tokens, 10_200 + delta_bytes / 4);
}
#[test]
fn total_zero_history_is_zero() {
let est = total_tokens(&[], &[]);

View File

@ -331,7 +331,7 @@ impl crate::WebSearchConfig {
Self {
enabled: upper.enabled.or(self.enabled),
provider: upper.provider.or(self.provider),
api_key_env: upper.api_key_env.or(self.api_key_env),
api_key_secret: upper.api_key_secret.or(self.api_key_secret),
timeout_secs: upper.timeout_secs.or(self.timeout_secs),
base_url: upper.base_url.or(self.base_url),
country: upper.country.or(self.country),
@ -517,7 +517,7 @@ fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError>
}
}
/// `AuthRef::ApiKey { file, .. }` が相対パスのとき `base` を前置する。
/// `AuthRef::ApiKey { file }` が相対パスのとき `base` を前置する。
fn resolve_auth_file(auth: &mut Option<AuthRef>, base: &Path) {
if let Some(AuthRef::ApiKey { file: Some(p), .. }) = auth.as_mut() {
*p = join_if_relative(base, p);
@ -692,10 +692,7 @@ mod tests {
}
fn api_key_file_auth(path: PathBuf) -> AuthRef {
AuthRef::ApiKey {
env: None,
file: Some(path),
}
AuthRef::ApiKey { file: Some(path) }
}
fn minimal_valid() -> PodManifestConfig {
@ -1089,7 +1086,7 @@ mod tests {
}),
web: Some(WebConfig {
search: Some(crate::WebSearchConfig {
api_key_env: Some("LOWER_BRAVE_KEY".into()),
api_key_secret: Some("web/brave/lower".into()),
timeout_secs: Some(12),
..Default::default()
}),
@ -1118,7 +1115,7 @@ mod tests {
assert_eq!(c.prune_protected_tokens, Some(5_000));
let search = merged.web.unwrap().search.unwrap();
assert_eq!(search.timeout_secs, Some(3));
assert_eq!(search.api_key_env.as_deref(), Some("LOWER_BRAVE_KEY"));
assert_eq!(search.api_key_secret.as_deref(), Some("web/brave/lower"));
}
#[test]

View File

@ -122,10 +122,10 @@ pub struct WebSearchConfig {
pub enabled: Option<bool>,
#[serde(default)]
pub provider: Option<WebSearchProvider>,
/// Environment variable that stores the provider API key. Raw secrets do
/// not belong in manifest files.
/// Local secret-store id for the provider API key. Raw secrets do not
/// belong in manifest files.
#[serde(default)]
pub api_key_env: Option<String>,
pub api_key_secret: Option<String>,
/// Request timeout in seconds. Tool implementation applies a safe default
/// when this is omitted.
#[serde(default)]
@ -651,7 +651,7 @@ permission = "write"
#[test]
fn parse_web_config() {
let toml = format!(
"{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_env = \"BRAVE_SEARCH_API_KEY\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n",
"{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_secret = \"web/brave/default\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n",
MINIMAL_REQUIRED
);
let manifest = PodManifest::from_toml(&toml).unwrap();

View File

@ -97,7 +97,7 @@ pub enum SchemeKind {
/// 認証の参照。
///
/// 実際のトークン値の解決(env / file 読取、OAuth refresh 等)は
/// 実際のトークン値の解決(local secret store / file 読取、OAuth refresh 等)は
/// `crates/provider` で行う。ここはあくまで「どこから取るか」の宣言。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(tag = "kind", rename_all = "snake_case")]
@ -105,11 +105,10 @@ pub enum AuthRef {
/// 認証不要(ローカル Ollama 等)
#[default]
None,
/// API key。env / file のいずれか(両方指定された場合は env が優先)
/// API key file reference. Prefer [`AuthRef::SecretRef`] for normal
/// provider credentials; this remains an explicit file source for low-level
/// manifests and tests.
ApiKey {
/// 環境変数名。未指定のときは scheme ごとの既定(`INSOMNIA_API_KEY_*`
#[serde(default)]
env: Option<String>,
/// key を書き込んだファイル(絶対パス)
#[serde(default)]
file: Option<PathBuf>,
@ -117,8 +116,8 @@ pub enum AuthRef {
/// ChatGPT OAuth`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット
#[serde(rename = "codex_oauth")]
CodexOAuth,
/// Typed secret-store reference. The profile resolver preserves this
/// reference verbatim; secret-store lookup/decryption is intentionally a
/// Typed local secret-store reference. The profile resolver preserves this
/// reference verbatim; secret-store lookup/deobfuscation is intentionally a
/// later consumer-boundary concern.
#[serde(rename = "secret_ref")]
SecretRef {
@ -126,16 +125,3 @@ pub enum AuthRef {
ref_: String,
},
}
impl SchemeKind {
/// 既定の環境変数名(`INSOMNIA_API_KEY_*`)。
///
/// `AuthRef::ApiKey { env: None, .. }` の env 未指定時に使う。
pub fn default_env_var(self) -> &'static str {
match self {
Self::Anthropic => "INSOMNIA_API_KEY_ANTHROPIC",
Self::OpenaiChat | Self::OpenaiResponses => "INSOMNIA_API_KEY_OPENAI",
Self::Gemini => "INSOMNIA_API_KEY_GEMINI",
}
}
}

View File

@ -1038,9 +1038,7 @@ fn reject_absolute_auth_file(
auth: &Option<AuthRef>,
field: &'static str,
) -> Result<(), ProfileError> {
if let Some(AuthRef::ApiKey {
file: Some(file), ..
}) = auth
if let Some(AuthRef::ApiKey { file: Some(file) }) = auth
&& file.is_absolute()
{
return Err(ProfileError::InvalidProfile(format!(

View File

@ -640,6 +640,49 @@ mod tests {
assert_eq!(count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn pre_llm_request_does_not_yield_from_single_measurement_history_rate_projection() {
let count = Arc::new(AtomicUsize::new(0));
let registry = registry_with_pre_llm_hook(count.clone());
let ctx_items = vec![
Item::user_message("first"),
Item::user_message("tool output ".repeat(400)),
];
let record = UsageRecord {
history_len: 1,
input_total_tokens: 11_124,
cache_read_tokens: 0,
cache_write_tokens: 0,
output_tokens: 0,
};
let prefix = llm_worker::token_counter::prefix_bytes(&ctx_items);
let delta_bytes = prefix[2].saturating_sub(prefix[1]);
let old_projection =
11_124 + (delta_bytes as u128 * 11_124_u128 / prefix[1] as u128) as u64;
let corrected = total_tokens(&ctx_items, std::slice::from_ref(&record)).tokens;
let threshold = corrected + 100;
assert!(old_projection > threshold);
let state = Arc::new(CompactState::new(None, Some(threshold), 2));
let history = Arc::new(Mutex::new(vec![record]));
let interceptor = PodInterceptor::new(
registry,
Some(state),
Some(history),
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
TaskStore::new(),
Arc::new(TaskReminderState::new()),
PromptCatalog::builtins_only().unwrap(),
None,
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
assert!(matches!(action, PreRequestAction::Continue));
assert_eq!(count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn pre_llm_request_does_not_yield_when_only_post_run_threshold_set() {
// request_threshold = None → safety-net check is inert inside the turn

View File

@ -92,6 +92,9 @@ pub enum PodPrompt {
/// knowledge when Workflow resident injection is enabled and at least one
/// workflow advertises `model_invokation: true`.
ResidentWorkflowsSection,
/// Trailing Pod orchestration guidance, appended when registered tools
/// include Pod-management capabilities.
PodOrchestrationGuidanceSection,
/// LLM-facing description for the SpawnPod tool, including discovered
/// profile selectors.
SpawnPodToolDescription,
@ -111,6 +114,7 @@ impl PodPrompt {
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
Self::ResidentKnowledgeSection => "resident_knowledge_section",
Self::ResidentWorkflowsSection => "resident_workflows_section",
Self::PodOrchestrationGuidanceSection => "pod_orchestration_guidance_section",
Self::SpawnPodToolDescription => "spawn_pod_tool_description",
}
}
@ -130,6 +134,7 @@ impl PodPrompt {
PodPrompt::ResidentMemorySummarySection,
PodPrompt::ResidentKnowledgeSection,
PodPrompt::ResidentWorkflowsSection,
PodPrompt::PodOrchestrationGuidanceSection,
PodPrompt::SpawnPodToolDescription,
];
@ -145,6 +150,7 @@ impl PodPrompt {
"resident_memory_summary_section",
"resident_knowledge_section",
"resident_workflows_section",
"pod_orchestration_guidance_section",
"spawn_pod_tool_description",
];
}
@ -376,11 +382,21 @@ impl PromptCatalog {
/// Render `PodPrompt::ResidentKnowledgeSection` with `{{ entries }}`
/// (a pre-formatted list block authored by the caller).
pub fn resident_knowledge_section(&self, entries: &str) -> Result<String, CatalogError> {
self.render(
PodPrompt::ResidentKnowledgeSection,
single("entries", entries),
)
pub fn resident_knowledge_section(
&self,
entries: &str,
knowledge_query_available: bool,
memory_read_available: bool,
) -> Result<String, CatalogError> {
use std::collections::BTreeMap;
let mut m: BTreeMap<&'static str, Value> = BTreeMap::new();
m.insert("entries", Value::from(entries));
m.insert(
"knowledge_query_available",
Value::from(knowledge_query_available),
);
m.insert("memory_read_available", Value::from(memory_read_available));
self.render(PodPrompt::ResidentKnowledgeSection, Value::from(m))
}
/// Render `PodPrompt::ResidentWorkflowsSection` with `{{ entries }}`
@ -392,6 +408,11 @@ impl PromptCatalog {
)
}
/// Render `PodPrompt::PodOrchestrationGuidanceSection` (no inputs).
pub fn pod_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
self.render(PodPrompt::PodOrchestrationGuidanceSection, Value::UNDEFINED)
}
/// Render `PodPrompt::SpawnPodToolDescription`.
pub fn spawn_pod_tool_description(
&self,
@ -537,6 +558,7 @@ mod tests {
for rendered in [compact, extract, consolidate] {
assert!(!rendered.contains("### Memory and knowledge"));
assert!(!rendered.contains("Do not query memory every turn"));
assert!(!rendered.contains("Strong lookup triggers include"));
}
}
@ -704,6 +726,19 @@ compact_system = "PREFIX\n{% include \"$insomnia/internal/compact_system\" %}"
assert!(rendered.contains("write_summary"));
}
#[test]
fn pod_orchestration_guidance_section_renders_resource_body() {
let cat = PromptCatalog::builtins_only().unwrap();
let rendered = cat.pod_orchestration_guidance_section().unwrap();
assert!(rendered.contains("## Pod orchestration"));
assert!(rendered.contains("spawned Pod notifications are background signals"));
assert!(rendered.contains("does not need to keep a turn open"));
assert!(rendered.contains("Do not use `sleep` or polling loops"));
assert!(rendered.contains("worktree status, diff, and test results"));
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
assert!(rendered.contains("bypass user/workflow authorization"));
}
#[test]
fn spawn_pod_tool_description_renders_profile_block() {
let cat = PromptCatalog::builtins_only().unwrap();

View File

@ -7,10 +7,11 @@
//! eagerly syntax-checks it at Pod construction. The final system
//! prompt is materialised exactly once just before the first LLM turn:
//! the rendered body is appended with a fixed trailing section carrying
//! the Pod's `Scope` summary and (if present) the project's `AGENTS.md`
//! contents plus resident memory sections, and the whole string is handed
//! to the Worker via `set_system_prompt`. Subsequent turns and compactions
//! reuse that materialised string verbatim.
//! the Pod's `Scope` summary, (if present) the project's `AGENTS.md`
//! contents, resident memory sections, and conditional Pod-orchestration
//! guidance, then the whole string is handed to the Worker via
//! `set_system_prompt`. Subsequent turns and compactions reuse that
//! materialised string verbatim.
use std::collections::BTreeMap;
use std::path::Path;
@ -125,6 +126,7 @@ impl SystemPromptTemplate {
ctx.resident_summary,
ctx.resident_knowledge,
ctx.resident_workflows,
ToolCapabilities::from_tool_names(&ctx.tool_names),
)
}
}
@ -199,17 +201,101 @@ impl<'a> SystemPromptContext<'a> {
.collect::<Vec<_>>(),
),
);
root.insert(
"tool_capabilities".into(),
ToolCapabilities::from_tool_names(&self.tool_names).to_minijinja_value(),
);
Value::from(root)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct ToolCapabilities {
memory_query: bool,
knowledge_query: bool,
memory_read: bool,
memory_write: bool,
memory_edit: bool,
memory_delete: bool,
pod_spawn: bool,
pod_send: bool,
pod_read_output: bool,
pod_stop: bool,
pod_list: bool,
pod_restore: bool,
}
impl ToolCapabilities {
fn from_tool_names(names: &[String]) -> Self {
let mut capabilities = Self::default();
for name in names {
match name.as_str() {
"MemoryQuery" => capabilities.memory_query = true,
"KnowledgeQuery" => capabilities.knowledge_query = true,
"MemoryRead" => capabilities.memory_read = true,
"MemoryWrite" => capabilities.memory_write = true,
"MemoryEdit" => capabilities.memory_edit = true,
"MemoryDelete" => capabilities.memory_delete = true,
"SpawnPod" => capabilities.pod_spawn = true,
"SendToPod" => capabilities.pod_send = true,
"ReadPodOutput" => capabilities.pod_read_output = true,
"StopPod" => capabilities.pod_stop = true,
"ListPods" => capabilities.pod_list = true,
"RestorePod" => capabilities.pod_restore = true,
_ => {}
}
}
capabilities
}
fn memory_records(self) -> bool {
self.memory_query
|| self.memory_read
|| self.memory_write
|| self.memory_edit
|| self.memory_delete
}
fn memory_any(self) -> bool {
self.memory_records() || self.knowledge_query
}
fn memory_mutation(self) -> bool {
self.memory_write || self.memory_edit || self.memory_delete
}
fn pod_management(self) -> bool {
self.pod_spawn
|| self.pod_send
|| self.pod_read_output
|| self.pod_stop
|| self.pod_list
|| self.pod_restore
}
fn to_minijinja_value(self) -> Value {
let mut map: BTreeMap<&'static str, Value> = BTreeMap::new();
map.insert("memory_any", Value::from(self.memory_any()));
map.insert("memory_records", Value::from(self.memory_records()));
map.insert("memory_query", Value::from(self.memory_query));
map.insert("knowledge_query", Value::from(self.knowledge_query));
map.insert("memory_read", Value::from(self.memory_read));
map.insert("memory_write", Value::from(self.memory_write));
map.insert("memory_edit", Value::from(self.memory_edit));
map.insert("memory_delete", Value::from(self.memory_delete));
map.insert("memory_mutation", Value::from(self.memory_mutation()));
map.insert("pod_management", Value::from(self.pod_management()));
Value::from(map)
}
}
/// Build the final system prompt by appending the fixed trailing
/// section to `body`. The Rust side owns the layout (blank-line
/// separators, trailing-whitespace trim); each section's header + body
/// comes from the prompt catalog (`PodPrompt::WorkingBoundariesSection`
/// / `PodPrompt::AgentsMdSection`) so that wording can be overridden
/// per-pack without touching this function.
pub fn append_trailing_section(
fn append_trailing_section(
body: &str,
prompts: &PromptCatalog,
scope: &Scope,
@ -217,6 +303,7 @@ pub fn append_trailing_section(
resident_summary: Option<&str>,
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
resident_workflows: Option<&[ResidentWorkflowEntry]>,
tool_capabilities: ToolCapabilities,
) -> Result<String, SystemPromptError> {
let mut out = String::with_capacity(body.len() + 256);
out.push_str(body);
@ -247,7 +334,11 @@ pub fn append_trailing_section(
if !entries.is_empty() {
out.push('\n');
let formatted = format_resident_knowledge_entries(entries);
let section = prompts.resident_knowledge_section(&formatted)?;
let section = prompts.resident_knowledge_section(
&formatted,
tool_capabilities.knowledge_query,
tool_capabilities.memory_read,
)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
@ -261,6 +352,12 @@ pub fn append_trailing_section(
out.push('\n');
}
}
if tool_capabilities.pod_management() {
out.push('\n');
let section = prompts.pod_orchestration_guidance_section()?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
// Canonicalise the tail so the emitted prompt has a single form
// regardless of how individual templates chose to end.
while out.ends_with('\n') || out.ends_with(' ') {
@ -414,6 +511,34 @@ mod tests {
}
}
fn memory_tool_names() -> Vec<String> {
[
"MemoryQuery",
"KnowledgeQuery",
"MemoryRead",
"MemoryWrite",
"MemoryEdit",
"MemoryDelete",
]
.into_iter()
.map(String::from)
.collect()
}
fn pod_management_tool_names() -> Vec<String> {
[
"SpawnPod",
"SendToPod",
"ReadPodOutput",
"StopPod",
"ListPods",
"RestorePod",
]
.into_iter()
.map(String::from)
.collect()
}
/// Lazily-initialised builtin catalog shared across system-prompt
/// tests, so every `ctx()` can hand out a `&'static PromptCatalog`
/// reference without forcing test bodies to create one per call.
@ -438,13 +563,15 @@ mod tests {
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl
.render(&ctx(dir.path(), &scope, vec!["Read".into()], None))
.render(&ctx(dir.path(), &scope, memory_tool_names(), None))
.unwrap();
// Builtin default body must expose the tool and language policies.
assert!(rendered.contains("### Memory and knowledge"));
assert!(rendered.contains("MemoryQuery"));
assert!(rendered.contains("small targeted `MemoryQuery` / `KnowledgeQuery`"));
assert!(rendered.contains("Strong lookup triggers include"));
assert!(rendered.contains("MemoryRead(kind=summary)"));
assert!(rendered.contains("Do not query memory every turn"));
assert!(rendered.contains("MemoryWrite"));
assert!(rendered.contains("## Language"));
assert!(rendered.contains("`language`: `match the user's language"));
// Trailing section must be present.
@ -452,6 +579,96 @@ mod tests {
assert!(rendered.contains("Readable:"));
}
#[test]
fn instruction_default_omits_memory_guidance_without_memory_tools() {
let loader = PromptLoader::builtins_only();
let tmpl = SystemPromptTemplate::parse("$insomnia/default", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl
.render(&ctx(
dir.path(),
&scope,
vec!["Read".into(), "Edit".into()],
None,
))
.unwrap();
assert!(!rendered.contains("### Memory and knowledge"));
assert!(!rendered.contains("MemoryQuery"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryRead"));
assert!(!rendered.contains("MemoryWrite"));
assert!(!rendered.contains("MemoryEdit"));
assert!(!rendered.contains("MemoryDelete"));
assert!(rendered.contains("## Language"));
assert!(rendered.contains("## Working boundaries"));
}
#[test]
fn memory_guidance_names_only_available_memory_tools() {
let loader = PromptLoader::builtins_only();
let tmpl = SystemPromptTemplate::parse("$insomnia/default", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl
.render(&ctx(
dir.path(),
&scope,
vec!["MemoryQuery".into(), "MemoryRead".into()],
None,
))
.unwrap();
assert!(rendered.contains("### Memory and knowledge"));
assert!(rendered.contains("small targeted `MemoryQuery`"));
assert!(rendered.contains("MemoryRead(kind=summary)"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryWrite"));
assert!(!rendered.contains("MemoryEdit"));
assert!(!rendered.contains("MemoryDelete"));
}
#[test]
fn pod_orchestration_guidance_is_included_for_pod_management_tools() {
let loader = PromptLoader::builtins_only();
let tmpl = SystemPromptTemplate::parse("$insomnia/default", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl
.render(&ctx(dir.path(), &scope, pod_management_tool_names(), None))
.unwrap();
assert!(rendered.contains("## Pod orchestration"));
assert!(rendered.contains("spawned Pod notifications are background signals"));
assert!(rendered.contains("does not need to keep a turn open"));
assert!(rendered.contains("Do not use `sleep` or polling loops"));
assert!(rendered.contains("worktree status, diff, and test results"));
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
assert!(rendered.contains("bypass user/workflow authorization"));
}
#[test]
fn pod_orchestration_guidance_is_omitted_without_pod_management_tools() {
let loader = PromptLoader::builtins_only();
let tmpl = SystemPromptTemplate::parse("$insomnia/default", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl
.render(&ctx(
dir.path(),
&scope,
vec!["Read".into(), "Edit".into(), "MemoryRead".into()],
None,
))
.unwrap();
assert!(!rendered.contains("## Pod orchestration"));
assert!(!rendered.contains("spawned Pod notifications are background signals"));
assert!(!rendered.contains("does not need to keep a turn open"));
assert!(!rendered.contains("Do not use `sleep` or polling loops"));
}
#[test]
fn instruction_prefix_addressing_user() {
let (_tmp, loader) = user_loader_with("greet.md", "HELLO from {{ cwd }}");
@ -706,12 +923,32 @@ mod tests {
assert!(rendered.contains("- alpha: first record"));
// Newline in description is folded to a space (one entry per line).
assert!(rendered.contains("- beta: second record with newline"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryRead"));
// Resident section sits *after* the working-boundaries header.
let pos_boundaries = rendered.find("## Working boundaries").unwrap();
let pos_resident = rendered.find("## Resident knowledge").unwrap();
assert!(pos_resident > pos_boundaries);
}
#[test]
fn trailing_section_mentions_resident_knowledge_tools_when_available() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let entries = [ResidentKnowledgeEntry {
slug: "alpha".into(),
description: "first record".into(),
}];
let mut context = ctx_with_resident(dir.path(), &scope, &entries);
context.tool_names = memory_tool_names();
let rendered = tmpl.render(&context).unwrap();
assert!(rendered.contains("## Resident knowledge"));
assert!(rendered.contains("KnowledgeQuery / MemoryRead"));
}
#[test]
fn trailing_section_renders_resident_workflows() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");

View File

@ -991,7 +991,6 @@ return profile {
base_url: Some("https://example.test".into()),
model_id: Some("claude-sonnet-4".into()),
auth: Some(AuthRef::ApiKey {
env: None,
file: Some(PathBuf::from("/etc/keys/anthropic")),
}),
..Default::default()

View File

@ -10,6 +10,7 @@ base64 = "0.22.1"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
llm-worker = { workspace = true }
manifest = { workspace = true }
secrets = { workspace = true }
reqwest = { version = "0.13", features = ["json", "native-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@ -1,6 +1,6 @@
# provider
マニフェストの `ModelManifest` から適切な `LlmClient``HttpTransport<S>`)を構築するファクトリクレート。プロバイダ / モデルカタログの解決、API キーの環境変数 / ファイル解決、scheme ↔ auth の整合検証を担う。
マニフェストの `ModelManifest` から適切な `LlmClient``HttpTransport<S>`)を構築するファクトリクレート。プロバイダ / モデルカタログの解決、API キーの local secret store / 明示ファイル解決、scheme ↔ auth の整合検証を担う。
## 公開型
@ -14,7 +14,7 @@
- プロバイダ / モデルカタログの builtin (`resources/{providers,models}/builtin.toml`) と user override (`$XDG_CONFIG_HOME/insomnia/{providers,models}.toml`) の解決
- `ModelManifest` の ref 形を `(provider, model_id)` に split し、`ModelConfig` へ展開
- `AuthRef::ApiKey` を `ResolvedAuth::ApiKey` に解決(env → file の優先順位
- `AuthRef::SecretRef` / `AuthRef::ApiKey` を `ResolvedAuth::ApiKey` に解決(通常は local secret store、低レベル manifest では明示ファイルも可
- `AuthRef::None` / `AuthRef::CodexOAuth` の解決
- `Scheme::required_auth()``ResolvedAuth` の妥当性検証(非対応組合せは構築エラー)
- capability は manifest 明示 > model catalog > provider.default_capability > `Scheme::default_capability()` の順で解決

View File

@ -72,10 +72,15 @@ pub enum ResolveError {
pub enum AuthHint {
/// 認証不要(ローカル Ollama 等)
None,
/// API key。`env` が指定されていれば UI はその env 名を提示する
ApiKey {
#[serde(default)]
env: Option<String>,
/// API key file reference. Normal credential configuration should prefer
/// [`AuthHint::SecretRef`] so plaintext credentials stay out of manifests.
ApiKey,
/// Local secret-store reference. The catalog/profile explicitly chooses the
/// logical key id; the secret store itself has no provider semantics.
#[serde(rename = "secret_ref")]
SecretRef {
#[serde(rename = "ref")]
ref_: String,
},
/// ChatGPT OAuth`~/.codex/auth.json`
#[serde(rename = "codex_oauth")]
@ -153,16 +158,12 @@ struct ModelCatalogFile {
model: Vec<ModelEntry>,
}
/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。env / file は
/// マニフェスト側で override 可能なので、ここでは hint そのままを
/// 反映した最小形だけを返す(`AuthRef::ApiKey { env: hint_env, file: None }`)。
/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。
fn auth_hint_to_ref(hint: &AuthHint) -> AuthRef {
match hint {
AuthHint::None => AuthRef::None,
AuthHint::ApiKey { env } => AuthRef::ApiKey {
env: env.clone(),
file: None,
},
AuthHint::ApiKey => AuthRef::ApiKey { file: None },
AuthHint::SecretRef { ref_ } => AuthRef::SecretRef { ref_: ref_.clone() },
AuthHint::CodexOAuth => AuthRef::CodexOAuth,
}
}
@ -415,11 +416,10 @@ mod tests {
assert_eq!(cfg.model_id, "claude-sonnet-4-6");
assert_eq!(cfg.base_url.as_deref(), Some("https://api.anthropic.com"));
match cfg.auth {
AuthRef::ApiKey { env, file } => {
assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_ANTHROPIC"));
assert!(file.is_none());
AuthRef::SecretRef { ref_ } => {
assert_eq!(ref_, "providers/anthropic/default");
}
_ => panic!("expected ApiKey auth from provider hint"),
_ => panic!("expected SecretRef auth from provider hint"),
}
assert!(
cfg.capability.is_some(),
@ -493,15 +493,13 @@ mod tests {
let manifest = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()),
auth: Some(AuthRef::ApiKey {
env: None,
file: Some(PathBuf::from("/tmp/sk-ant")),
}),
..Default::default()
};
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
match cfg.auth {
AuthRef::ApiKey { env, file } => {
assert!(env.is_none());
AuthRef::ApiKey { file } => {
assert_eq!(file.as_deref(), Some(Path::new("/tmp/sk-ant")));
}
_ => panic!("override auth should win"),
@ -555,7 +553,6 @@ mod tests {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("claude-sonnet-4-6".into()),
auth: Some(AuthRef::ApiKey {
env: None,
file: Some(PathBuf::from("/tmp/sk")),
}),
..Default::default()
@ -575,7 +572,6 @@ mod tests {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("claude-sonnet-4-6".into()),
auth: Some(AuthRef::ApiKey {
env: None,
file: Some(PathBuf::from("/tmp/sk")),
}),
context_window: Some(777_000),

View File

@ -4,7 +4,7 @@
//! 段階:
//! 1. `ModelManifest` を [`catalog::resolve_model_manifest`] で
//! カタログ込み [`ModelConfig`] に解決ref → 展開 / inline → 検証)
//! 2. `AuthRef` を環境変数 / ファイルから解決して [`ResolvedAuth`] に
//! 2. `AuthRef` を local secret store / ファイルから解決して [`ResolvedAuth`] に
//! 3. `scheme.required_auth()` と解決値を照合(非対応組合せは構築エラー)
//! 4. `ModelCapability` は manifest 明示 > model catalog > provider
//! default_capability > scheme 既定 の順でフォールバック(上位 3 段は
@ -30,6 +30,7 @@ use llm_worker::llm_client::{
};
use manifest::{AuthRef, ModelManifest, SchemeKind};
use secrets::{SecretStore, SecretValue};
pub use catalog::{ModelConfig, ResolveError as CatalogResolveError};
@ -42,6 +43,13 @@ pub enum ProviderError {
#[error("API key not provided for scheme {scheme:?}")]
ApiKeyMissing { scheme: SchemeKind },
#[error("failed to resolve secret `{id}`: {source}")]
SecretStore {
id: String,
#[source]
source: secrets::Error,
},
#[error("scheme {scheme:?} does not support this auth")]
AuthMismatch { scheme: SchemeKind },
@ -53,21 +61,38 @@ pub enum ProviderError {
}
/// `AuthRef` をランタイムで使える [`ResolvedAuth`] に解決する。
///
/// 解決順:
/// 1. `AuthRef::ApiKey { env, .. }` で env が指定されていればその変数を参照
/// 2. そうでなければ scheme 既定の環境変数 (`SchemeKind::default_env_var`)
/// 3. それでも無ければ `file` を読む(絶対パスのみ)
fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, ProviderError> {
let resolver = DefaultSecretResolver;
resolve_auth_with_resolver(scheme, auth, &resolver)
}
trait SecretResolver {
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error>;
}
struct DefaultSecretResolver;
impl SecretResolver for DefaultSecretResolver {
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error> {
let data_dir = manifest::paths::data_dir().ok_or_else(|| secrets::Error::Read {
path: std::path::PathBuf::from("<data_dir>"),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not determine insomnia data directory",
),
})?;
SecretStore::new(data_dir).get(id)
}
}
fn resolve_auth_with_resolver(
scheme: SchemeKind,
auth: &AuthRef,
resolver: &dyn SecretResolver,
) -> Result<ResolvedAuth, ProviderError> {
match auth {
AuthRef::None => Ok(ResolvedAuth::None),
AuthRef::ApiKey { env, file } => {
let env_name = env.as_deref().unwrap_or(scheme.default_env_var());
if let Ok(val) = std::env::var(env_name)
&& !val.is_empty()
{
return Ok(ResolvedAuth::ApiKey(val));
}
AuthRef::ApiKey { file } => {
if let Some(path) = file {
if !path.is_absolute() {
return Err(ProviderError::Config(format!(
@ -90,9 +115,15 @@ fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, Prov
.map_err(|e| ProviderError::Config(e.to_string()))?;
Ok(ResolvedAuth::Custom(Arc::new(provider)))
}
AuthRef::SecretRef { ref_ } => Err(ProviderError::Config(format!(
"secret store references are not implemented yet: {ref_}"
))),
AuthRef::SecretRef { ref_ } => {
let value = resolver
.get_secret(ref_)
.map_err(|source| ProviderError::SecretStore {
id: ref_.clone(),
source,
})?;
Ok(ResolvedAuth::ApiKey(value.into_string()))
}
}
}
@ -179,15 +210,24 @@ mod tests {
use std::io::Write;
use std::path::PathBuf;
struct TestSecrets(std::collections::BTreeMap<String, String>);
impl SecretResolver for TestSecrets {
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error> {
self.0
.get(id)
.cloned()
.map(SecretValue::new)
.ok_or_else(|| secrets::Error::NotFound { id: id.to_string() })
}
}
fn anthropic_config() -> ModelConfig {
ModelConfig {
scheme: SchemeKind::Anthropic,
base_url: None,
model_id: "claude-sonnet-4-20250514".into(),
auth: AuthRef::ApiKey {
env: None,
file: None,
},
auth: AuthRef::ApiKey { file: None },
capability: None,
context_window: 200_000,
max_context_window: None,
@ -195,18 +235,41 @@ mod tests {
}
#[test]
#[serial]
fn resolve_from_env() {
let env_name = SchemeKind::Anthropic.default_env_var();
unsafe { std::env::set_var(env_name, "sk-from-env") };
let auth = resolve_auth(SchemeKind::Anthropic, &anthropic_config().auth).unwrap();
unsafe { std::env::remove_var(env_name) };
fn resolve_from_secret_ref() {
let resolver = TestSecrets(std::collections::BTreeMap::from([(
"providers/anthropic/default".to_string(),
"sk-from-store".to_string(),
)]));
let auth = resolve_auth_with_resolver(
SchemeKind::Anthropic,
&AuthRef::SecretRef {
ref_: "providers/anthropic/default".into(),
},
&resolver,
)
.unwrap();
match auth {
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"),
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-store"),
_ => panic!("expected ApiKey"),
}
}
#[test]
fn missing_secret_names_only_id() {
let resolver = TestSecrets(Default::default());
let err = resolve_auth_with_resolver(
SchemeKind::Anthropic,
&AuthRef::SecretRef {
ref_: "providers/anthropic/missing".into(),
},
&resolver,
)
.unwrap_err();
let message = err.to_string();
assert!(message.contains("providers/anthropic/missing"));
assert!(!message.contains("sk-"));
}
#[test]
fn resolve_from_file() {
let dir = tempfile::tempdir().unwrap();
@ -217,7 +280,6 @@ mod tests {
}
let config = ModelConfig {
auth: AuthRef::ApiKey {
env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()),
file: Some(key_path),
},
..anthropic_config()
@ -229,36 +291,10 @@ mod tests {
}
}
#[test]
#[serial]
fn env_takes_precedence_over_file() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("key.txt");
std::fs::write(&key_path, "sk-from-file").unwrap();
let env_name = SchemeKind::Anthropic.default_env_var();
unsafe { std::env::set_var(env_name, "sk-from-env") };
let config = ModelConfig {
auth: AuthRef::ApiKey {
env: None,
file: Some(key_path),
},
..anthropic_config()
};
let auth = resolve_auth(config.scheme, &config.auth).unwrap();
unsafe { std::env::remove_var(env_name) };
match auth {
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"),
_ => panic!("expected ApiKey"),
}
}
#[test]
fn relative_auth_file_is_rejected() {
let config = ModelConfig {
auth: AuthRef::ApiKey {
env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()),
file: Some(PathBuf::from("keys/anthropic")),
},
..anthropic_config()
@ -270,8 +306,6 @@ mod tests {
#[test]
#[serial]
fn missing_key_returns_api_key_missing() {
let env_name = SchemeKind::Anthropic.default_env_var();
unsafe { std::env::remove_var(env_name) };
let result = build_client_from_config(&anthropic_config());
assert!(matches!(result, Err(ProviderError::ApiKeyMissing { .. })));
}

14
crates/secrets/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "secrets"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

493
crates/secrets/src/lib.rs Normal file
View File

@ -0,0 +1,493 @@
use std::collections::BTreeMap;
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const STORE_VERSION: u32 = 1;
const KEY_LEN: usize = 32;
const TAG_LEN: usize = 32;
const MAX_ID_LEN: usize = 128;
static NONCE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("secret id is empty")]
EmptyId,
#[error("secret id `{id}` is too long (max {max} bytes)")]
IdTooLong { id: String, max: usize },
#[error("secret id `{0}` contains unsupported characters")]
UnsupportedIdChars(String),
#[error("secret id `{0}` must not be absolute or contain traversal components")]
UnsafeId(String),
#[error("failed to read secret store {}: {source}", .path.display())]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse secret store {}: {source}", .path.display())]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("unsupported secret store version {version} in {}", .path.display())]
UnsupportedVersion { path: PathBuf, version: u32 },
#[error("failed to decode secret `{id}`")]
Decode { id: String },
#[error("secret `{id}` was not found")]
NotFound { id: String },
#[error("failed to create secret store directory {}: {source}", .path.display())]
CreateDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write secret store {}: {source}", .path.display())]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct SecretId(String);
impl SecretId {
pub fn parse(value: impl Into<String>) -> Result<Self> {
let value = value.into();
validate_id(&value)?;
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SecretId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("SecretId").field(&self.0).finish()
}
}
impl fmt::Display for SecretId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct SecretValue(String);
impl SecretValue {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose_secret(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl fmt::Debug for SecretValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretValue([redacted])")
}
}
#[derive(Debug, Clone)]
pub struct SecretStore {
path: PathBuf,
key: [u8; KEY_LEN],
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct StoreFile {
version: u32,
#[serde(default)]
entries: BTreeMap<String, Entry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entry {
nonce: String,
ciphertext: String,
tag: String,
}
impl SecretStore {
pub fn new(data_dir: impl AsRef<Path>) -> Self {
let data_dir = data_dir.as_ref();
let path = data_dir.join("secrets").join("store.json");
Self::at_path_with_key(path, derive_key(data_dir))
}
pub fn at_path_for_tests(path: impl AsRef<Path>) -> Self {
let path = path.as_ref().to_path_buf();
Self::at_path_with_key(
path.clone(),
derive_key(path.parent().unwrap_or(Path::new(""))),
)
}
pub fn at_path_with_key(path: PathBuf, key: [u8; KEY_LEN]) -> Self {
Self { path, key }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn list_ids(&self) -> Result<Vec<SecretId>> {
let file = self.load()?;
file.entries.into_keys().map(SecretId::parse).collect()
}
pub fn get(&self, id: &str) -> Result<SecretValue> {
let id = SecretId::parse(id.to_string())?;
let file = self.load()?;
let entry = file
.entries
.get(id.as_str())
.ok_or_else(|| Error::NotFound { id: id.to_string() })?;
let plaintext = decrypt_entry(&self.key, &id, entry)?;
Ok(SecretValue::new(
String::from_utf8(plaintext).map_err(|_| Error::Decode { id: id.to_string() })?,
))
}
pub fn set(&self, id: &str, value: SecretValue) -> Result<()> {
let id = SecretId::parse(id.to_string())?;
let mut file = self.load()?;
file.entries.insert(
id.to_string(),
encrypt_entry(&self.key, &id, value.expose_secret().as_bytes()),
);
self.save(&file)
}
pub fn delete(&self, id: &str) -> Result<bool> {
let id = SecretId::parse(id.to_string())?;
let mut file = self.load()?;
let removed = file.entries.remove(id.as_str()).is_some();
if removed {
self.save(&file)?;
}
Ok(removed)
}
fn load(&self) -> Result<StoreFile> {
match fs::read_to_string(&self.path) {
Ok(text) => {
let file: StoreFile =
serde_json::from_str(&text).map_err(|source| Error::Parse {
path: self.path.clone(),
source,
})?;
if file.version != STORE_VERSION {
return Err(Error::UnsupportedVersion {
path: self.path.clone(),
version: file.version,
});
}
Ok(file)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(StoreFile {
version: STORE_VERSION,
entries: BTreeMap::new(),
}),
Err(source) => Err(Error::Read {
path: self.path.clone(),
source,
}),
}
}
fn save(&self, file: &StoreFile) -> Result<()> {
let parent = self.path.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent).map_err(|source| Error::CreateDir {
path: parent.to_path_buf(),
source,
})?;
let data = serde_json::to_vec_pretty(file).map_err(|source| Error::Write {
path: self.path.clone(),
source: std::io::Error::other(source),
})?;
let tmp = self.temp_path();
{
let mut fh = OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp)
.map_err(|source| Error::Write {
path: tmp.clone(),
source,
})?;
fh.write_all(&data).map_err(|source| Error::Write {
path: tmp.clone(),
source,
})?;
fh.sync_all().map_err(|source| Error::Write {
path: tmp.clone(),
source,
})?;
}
fs::rename(&tmp, &self.path).map_err(|source| Error::Write {
path: self.path.clone(),
source,
})?;
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
fn temp_path(&self) -> PathBuf {
let suffix = NONCE_COUNTER.fetch_add(1, Ordering::Relaxed);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let file_name = self
.path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("store.json");
self.path.with_file_name(format!(
".{file_name}.{}.{}.{}.tmp",
std::process::id(),
now,
suffix
))
}
}
pub fn validate_id(id: &str) -> Result<()> {
if id.is_empty() {
return Err(Error::EmptyId);
}
if id.len() > MAX_ID_LEN {
return Err(Error::IdTooLong {
id: id.to_string(),
max: MAX_ID_LEN,
});
}
if id.starts_with('/') || id.starts_with('~') || id.contains("//") {
return Err(Error::UnsafeId(id.to_string()));
}
for component in id.split('/') {
if component.is_empty() || component == "." || component == ".." {
return Err(Error::UnsafeId(id.to_string()));
}
}
if !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
{
return Err(Error::UnsupportedIdChars(id.to_string()));
}
Ok(())
}
fn derive_key(data_dir: &Path) -> [u8; KEY_LEN] {
let mut hasher = Sha256::new();
hasher.update(b"insomnia local secret store obfuscation key v1");
hasher.update(data_dir.as_os_str().as_encoded_bytes());
hasher.finalize().into()
}
fn encrypt_entry(key: &[u8; KEY_LEN], id: &SecretId, plaintext: &[u8]) -> Entry {
let nonce = make_nonce(id.as_str(), plaintext);
let ciphertext = xor_stream(key, &nonce, plaintext);
let tag = tag(key, id.as_str(), &nonce, &ciphertext);
Entry {
nonce: hex_encode(&nonce),
ciphertext: hex_encode(&ciphertext),
tag: hex_encode(&tag),
}
}
fn decrypt_entry(key: &[u8; KEY_LEN], id: &SecretId, entry: &Entry) -> Result<Vec<u8>> {
let nonce = hex_decode(&entry.nonce).map_err(|_| Error::Decode { id: id.to_string() })?;
let ciphertext =
hex_decode(&entry.ciphertext).map_err(|_| Error::Decode { id: id.to_string() })?;
let actual_tag = hex_decode(&entry.tag).map_err(|_| Error::Decode { id: id.to_string() })?;
let expected = tag(key, id.as_str(), &nonce, &ciphertext);
if actual_tag.as_slice() != expected {
return Err(Error::Decode { id: id.to_string() });
}
Ok(xor_stream(key, &nonce, &ciphertext))
}
fn make_nonce(id: &str, plaintext: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
hasher.update(b"insomnia nonce v1");
hasher.update(now.to_le_bytes());
hasher.update(std::process::id().to_le_bytes());
hasher.update(NONCE_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
hasher.update(id.as_bytes());
hasher.update(plaintext);
hasher.finalize()[..16].to_vec()
}
fn xor_stream(key: &[u8; KEY_LEN], nonce: &[u8], input: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(input.len());
let mut counter = 0u64;
for chunk in input.chunks(KEY_LEN) {
let mut hasher = Sha256::new();
hasher.update(b"insomnia secret keystream v1");
hasher.update(key);
hasher.update(nonce);
hasher.update(counter.to_le_bytes());
let block = hasher.finalize();
for (b, k) in chunk.iter().zip(block.iter()) {
out.push(b ^ k);
}
counter += 1;
}
out
}
fn tag(key: &[u8; KEY_LEN], id: &str, nonce: &[u8], ciphertext: &[u8]) -> [u8; TAG_LEN] {
let mut hasher = Sha256::new();
hasher.update(b"insomnia secret tag v1");
hasher.update(key);
hasher.update(id.as_bytes());
hasher.update(nonce);
hasher.update(ciphertext);
hasher.finalize().into()
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn hex_decode(s: &str) -> std::result::Result<Vec<u8>, ()> {
if !s.len().is_multiple_of(2) {
return Err(());
}
let mut out = Vec::with_capacity(s.len() / 2);
let bytes = s.as_bytes();
for pair in bytes.chunks_exact(2) {
let high = hex_value(pair[0])?;
let low = hex_value(pair[1])?;
out.push((high << 4) | low);
}
Ok(out)
}
fn hex_value(b: u8) -> std::result::Result<u8, ()> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_store() -> (tempfile::TempDir, SecretStore) {
let dir = tempfile::tempdir().unwrap();
let store =
SecretStore::at_path_with_key(dir.path().join("secrets/store.json"), [7u8; KEY_LEN]);
(dir, store)
}
#[test]
fn roundtrip_list_delete() {
let (_dir, store) = test_store();
store
.set("anthropic/default", SecretValue::new("sk-test-secret"))
.unwrap();
assert_eq!(
store.get("anthropic/default").unwrap().expose_secret(),
"sk-test-secret"
);
assert_eq!(store.list_ids().unwrap()[0].as_str(), "anthropic/default");
assert!(store.delete("anthropic/default").unwrap());
assert!(matches!(
store.get("anthropic/default"),
Err(Error::NotFound { .. })
));
}
#[test]
fn invalid_ids_are_rejected() {
for id in ["", "/abs", "../x", "x/../y", "x//y", "x y", "x\ny", "~home"] {
assert!(SecretId::parse(id).is_err(), "{id:?} should be invalid");
}
assert!(SecretId::parse("a".repeat(MAX_ID_LEN + 1)).is_err());
assert!(SecretId::parse("web/brave.default-1").is_ok());
}
#[test]
fn corrupted_store_fails_closed() {
let (dir, store) = test_store();
store
.set("web/brave", SecretValue::new("secret-value"))
.unwrap();
let path = dir.path().join("secrets/store.json");
let mut file: StoreFile =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let entry = file.entries.get_mut("web/brave").unwrap();
let replacement = if entry.ciphertext.starts_with('0') {
"1"
} else {
"0"
};
entry.ciphertext.replace_range(0..1, replacement);
fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
assert!(matches!(store.get("web/brave"), Err(Error::Decode { id }) if id == "web/brave"));
}
#[test]
fn plaintext_is_not_written_to_disk_or_debug() {
let (dir, store) = test_store();
let value = SecretValue::new("sk-plain-must-not-appear");
assert!(!format!("{value:?}").contains("sk-plain"));
store.set("provider/test", value).unwrap();
let text = fs::read_to_string(dir.path().join("secrets/store.json")).unwrap();
assert!(!text.contains("sk-plain-must-not-appear"));
assert!(text.contains("provider/test"));
}
#[test]
fn wrong_key_or_tamper_fails_decode() {
let (dir, store) = test_store();
store
.set("provider/test", SecretValue::new("secret-value"))
.unwrap();
let wrong =
SecretStore::at_path_with_key(dir.path().join("secrets/store.json"), [9u8; KEY_LEN]);
assert!(
matches!(wrong.get("provider/test"), Err(Error::Decode { id }) if id == "provider/test")
);
}
}

View File

@ -14,6 +14,7 @@ ignore = "0.4.25"
html5ever = "0.26"
llm-worker = { workspace = true }
manifest = { workspace = true }
secrets = { workspace = true }
markup5ever_rcdom = "0.2"
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
schemars = { workspace = true }

View File

@ -12,6 +12,7 @@ use markup5ever_rcdom::{Handle, NodeData, RcDom};
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, LOCATION};
use reqwest::{Client, Url};
use schemars::JsonSchema;
use secrets::SecretStore;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::net::lookup_host;
@ -36,6 +37,7 @@ const WEB_FETCH_TRUNCATION_MARKER: &str = "\n[truncated]";
pub struct WebTools {
config: Option<WebConfig>,
client: Client,
secret_store: Option<SecretStore>,
}
impl WebTools {
@ -45,7 +47,25 @@ impl WebTools {
.user_agent("insomnia-web-tools/0.1")
.build()
.expect("static reqwest client configuration is valid");
Self { config, client }
let secret_store = manifest::paths::data_dir().map(SecretStore::new);
Self {
config,
client,
secret_store,
}
}
#[cfg(test)]
fn with_client_and_secret_store(
config: Option<WebConfig>,
client: Client,
secret_store: Option<SecretStore>,
) -> Self {
Self {
config,
client,
secret_store,
}
}
fn global_enabled(&self) -> bool {
@ -153,11 +173,19 @@ impl WebTools {
match cfg.provider.ok_or_else(|| {
disabled_error(
"WebSearch",
"set web.search.provider = \"brave\" and web.search.api_key_env",
"set web.search.provider = \"brave\" and web.search.api_key_secret",
)
})? {
WebSearchProvider::Brave => {
brave_search(&self.client, cfg, &input.query, limit, offset).await
brave_search(
&self.client,
cfg,
self.secret_store.as_ref(),
&input.query,
limit,
offset,
)
.await
}
}
}
@ -213,28 +241,35 @@ pub fn web_fetch_tool(tools: WebTools) -> ToolDefinition {
async fn brave_search(
client: &Client,
cfg: &WebSearchConfig,
secret_store: Option<&SecretStore>,
query: &str,
limit: usize,
offset: usize,
) -> Result<ToolOutput, ToolError> {
let api_key_env = cfg.api_key_env.as_ref().ok_or_else(|| {
let api_key_secret = cfg.api_key_secret.as_ref().ok_or_else(|| {
disabled_error(
"WebSearch",
"set web.search.api_key_env to an environment variable containing the Brave API key",
"set web.search.api_key_secret to the insomnia keys secret id for the Brave API key",
)
})?;
let api_key = std::env::var(api_key_env).map_err(|_| {
let store = secret_store.ok_or_else(|| {
ToolError::ExecutionFailed(
"WebSearch provider is configured but the local secret store path is unavailable"
.into(),
)
})?;
let api_key = store.get(api_key_secret).map_err(|err| {
ToolError::ExecutionFailed(format!(
"WebSearch provider is configured but environment variable {api_key_env} is not set"
"WebSearch provider is configured but secret `{api_key_secret}` could not be resolved: {err}"
))
})?;
if api_key.trim().is_empty() {
if api_key.expose_secret().trim().is_empty() {
return Err(ToolError::ExecutionFailed(format!(
"WebSearch provider is configured but environment variable {api_key_env} is empty"
"WebSearch provider is configured but secret `{api_key_secret}` is empty"
)));
}
brave_search_with_api_key(client, cfg, &api_key, query, limit, offset).await
brave_search_with_api_key(client, cfg, api_key.expose_secret(), query, limit, offset).await
}
async fn brave_search_with_api_key(
@ -1709,7 +1744,7 @@ mod tests {
WebSearchConfig {
enabled: Some(true),
provider: Some(WebSearchProvider::Brave),
api_key_env: None,
api_key_secret: Some("web/brave/test".into()),
timeout_secs: Some(2),
base_url: Some(base_url),
..Default::default()
@ -2037,6 +2072,49 @@ mod tests {
assert_eq!(value.get("redirects").unwrap().as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn searches_brave_with_secret_ref() {
let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"web\":{\"results\":[{\"title\":\"Example\",\"url\":\"https://example.com\",\"description\":\"Snippet\"}]}}";
let (addr, captured) = serve_once_capture(response).await;
let dir = tempfile::tempdir().unwrap();
let store = SecretStore::at_path_for_tests(dir.path().join("secrets/store.json"));
store
.set(
"web/brave/test",
secrets::SecretValue::new("test-secret-ref"),
)
.unwrap();
let tools = WebTools::with_client_and_secret_store(
Some(WebConfig {
enabled: Some(true),
allow_private_addresses: Some(true),
search: Some(brave_search_config(format!("http://{addr}/search"))),
fetch: None,
}),
Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap(),
Some(store),
);
let result = tools
.run_search(WebSearchInput {
query: "insomnia".into(),
limit: Some(1),
offset: Some(0),
})
.await
.unwrap();
let value: Value = serde_json::from_str(result.content.as_deref().unwrap()).unwrap();
let request = captured.lock().await.clone().unwrap();
assert!(
request
.to_ascii_lowercase()
.contains("x-subscription-token: test-secret-ref\r\n")
);
assert_eq!(value["results"][0]["title"], "Example");
}
#[tokio::test]
async fn searches_brave_with_bounded_output() {
let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"web\":{\"results\":[{\"title\":\"Example\",\"url\":\"https://example.com\",\"description\":\"Snippet\",\"extra_snippets\":[\"Extra\"],\"language\":\"en\"}]}}";

View File

@ -15,6 +15,7 @@ unicode-width = "0.2.2"
uuid = { workspace = true }
toml = { workspace = true }
manifest = { workspace = true }
secrets = { workspace = true }
session-store = { workspace = true }
pod-store = { workspace = true }
pod-registry = { workspace = true }

434
crates/tui/src/keys.rs Normal file
View File

@ -0,0 +1,434 @@
use std::io::{self, Stdout};
use std::process::ExitCode;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use secrets::{SecretStore, SecretValue};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Mode {
Normal,
EditingId,
EditingValue { id: String },
ConfirmDelete { id: String },
}
#[derive(Clone)]
struct KeysApp {
ids: Vec<String>,
selected: usize,
mode: Mode,
input: String,
notice: String,
quit: bool,
}
impl std::fmt::Debug for KeysApp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let input = match self.mode {
Mode::EditingValue { .. } => "[redacted]".to_string(),
_ => self.input.clone(),
};
f.debug_struct("KeysApp")
.field("ids", &self.ids)
.field("selected", &self.selected)
.field("mode", &self.mode)
.field("input", &input)
.field("notice", &self.notice)
.field("quit", &self.quit)
.finish()
}
}
impl KeysApp {
fn new(ids: Vec<String>) -> Self {
let mut app = Self {
ids,
selected: 0,
mode: Mode::Normal,
input: String::new(),
notice: String::new(),
quit: false,
};
app.clamp_selection();
app
}
fn refresh(&mut self, ids: Vec<String>) {
self.ids = ids;
self.clamp_selection();
}
fn selected_id(&self) -> Option<&str> {
self.ids.get(self.selected).map(String::as_str)
}
fn clamp_selection(&mut self) {
if self.ids.is_empty() {
self.selected = 0;
} else if self.selected >= self.ids.len() {
self.selected = self.ids.len() - 1;
}
}
fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Action {
if modifiers.contains(KeyModifiers::CONTROL) && code == KeyCode::Char('c') {
self.quit = true;
return Action::Quit;
}
match self.mode.clone() {
Mode::Normal => self.handle_normal(code),
Mode::EditingId => self.handle_editing_id(code),
Mode::EditingValue { id } => self.handle_editing_value(code, id),
Mode::ConfirmDelete { id } => self.handle_confirm_delete(code, id),
}
}
fn handle_normal(&mut self, code: KeyCode) -> Action {
match code {
KeyCode::Char('q') | KeyCode::Esc => {
self.quit = true;
Action::Quit
}
KeyCode::Char('a') => {
self.input.clear();
self.notice = "Enter secret id, then Enter".into();
self.mode = Mode::EditingId;
Action::None
}
KeyCode::Char('d') => {
if let Some(id) = self.selected_id() {
self.mode = Mode::ConfirmDelete { id: id.to_string() };
self.notice = "Delete selected secret? y/N".into();
} else {
self.notice = "No key selected".into();
}
Action::None
}
KeyCode::Down | KeyCode::Char('j') => {
if !self.ids.is_empty() {
self.selected = (self.selected + 1).min(self.ids.len() - 1);
}
Action::None
}
KeyCode::Up | KeyCode::Char('k') => {
self.selected = self.selected.saturating_sub(1);
Action::None
}
_ => Action::None,
}
}
fn handle_editing_id(&mut self, code: KeyCode) -> Action {
match code {
KeyCode::Esc => {
self.mode = Mode::Normal;
self.input.clear();
self.notice = "Add cancelled".into();
Action::None
}
KeyCode::Enter => {
let id = self.input.trim().to_string();
if id.is_empty() {
self.notice = "Secret id must not be empty".into();
return Action::None;
}
self.input.clear();
self.notice = "Enter secret value; input is masked".into();
self.mode = Mode::EditingValue { id };
Action::None
}
KeyCode::Backspace => {
self.input.pop();
Action::None
}
KeyCode::Char(c) if !c.is_control() => {
self.input.push(c);
Action::None
}
_ => Action::None,
}
}
fn handle_editing_value(&mut self, code: KeyCode, id: String) -> Action {
match code {
KeyCode::Esc => {
self.mode = Mode::Normal;
self.input.clear();
self.notice = "Add cancelled".into();
Action::None
}
KeyCode::Enter => {
let value = std::mem::take(&mut self.input);
self.mode = Mode::Normal;
Action::Set { id, value }
}
KeyCode::Backspace => {
self.input.pop();
Action::None
}
KeyCode::Char(c) if !c.is_control() => {
self.input.push(c);
Action::None
}
_ => Action::None,
}
}
fn handle_confirm_delete(&mut self, code: KeyCode, id: String) -> Action {
self.mode = Mode::Normal;
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => Action::Delete { id },
_ => {
self.notice = "Delete cancelled".into();
Action::None
}
}
}
}
#[derive(Clone, PartialEq, Eq)]
enum Action {
None,
Set { id: String, value: String },
Delete { id: String },
Quit,
}
impl std::fmt::Debug for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => f.write_str("None"),
Self::Set { id, .. } => f
.debug_struct("Set")
.field("id", id)
.field("value", &"[redacted]")
.finish(),
Self::Delete { id } => f.debug_struct("Delete").field("id", id).finish(),
Self::Quit => f.write_str("Quit"),
}
}
}
pub async fn launch() -> ExitCode {
let data_dir = match manifest::paths::data_dir() {
Some(path) => path,
None => {
eprintln!("insomnia keys: could not determine insomnia data directory");
return ExitCode::FAILURE;
}
};
match run(SecretStore::new(data_dir)) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("insomnia keys: {err}");
ExitCode::FAILURE
}
}
}
type UiResult<T> = Result<T, Box<dyn std::error::Error>>;
struct TerminalRestoreGuard {
active: bool,
}
impl TerminalRestoreGuard {
fn new() -> Self {
Self { active: true }
}
fn restore(mut self) {
self.cleanup();
self.active = false;
}
fn cleanup(&mut self) {
let _ = execute!(io::stdout(), crossterm::cursor::Show, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
impl Drop for TerminalRestoreGuard {
fn drop(&mut self) {
if self.active {
self.cleanup();
}
}
}
fn run(store: SecretStore) -> UiResult<()> {
enable_raw_mode()?;
let guard = TerminalRestoreGuard::new();
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, crossterm::cursor::Hide)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_loop(&mut terminal, store);
drop(terminal);
guard.restore();
result
}
fn run_loop(terminal: &mut Terminal<CrosstermBackend<Stdout>>, store: SecretStore) -> UiResult<()> {
let mut app = KeysApp::new(load_ids(&store)?);
loop {
terminal.draw(|frame| draw(frame, &app))?;
if app.quit {
return Ok(());
}
if event::poll(Duration::from_millis(200))? {
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
match app.handle_key(key.code, key.modifiers) {
Action::None => {}
Action::Quit => return Ok(()),
Action::Set { id, value } => match store.set(&id, SecretValue::new(value)) {
Ok(()) => {
app.refresh(load_ids(&store)?);
app.notice = format!("Saved `{id}`");
}
Err(err) => {
app.notice = format!("Save failed for `{id}`: {err}");
}
},
Action::Delete { id } => match store.delete(&id) {
Ok(true) => {
app.refresh(load_ids(&store)?);
app.notice = format!("Deleted `{id}`");
}
Ok(false) => app.notice = format!("Secret `{id}` was already absent"),
Err(err) => app.notice = format!("Delete failed for `{id}`: {err}"),
},
}
}
}
}
fn load_ids(store: &SecretStore) -> UiResult<Vec<String>> {
Ok(store
.list_ids()?
.into_iter()
.map(|id| id.as_str().to_string())
.collect())
}
fn draw(frame: &mut ratatui::Frame<'_>, app: &KeysApp) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(4),
Constraint::Length(5),
])
.split(area);
let title = Paragraph::new("Local secret keys (values are never displayed)").block(
Block::default()
.borders(Borders::ALL)
.title("insomnia keys"),
);
frame.render_widget(title, chunks[0]);
let items: Vec<ListItem<'_>> = if app.ids.is_empty() {
vec![ListItem::new(Line::from(Span::raw("No keys stored")))]
} else {
app.ids
.iter()
.map(|id| ListItem::new(Line::from(Span::raw(id.clone()))))
.collect()
};
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Key ids"))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = ListState::default();
if !app.ids.is_empty() {
state.select(Some(app.selected));
}
frame.render_stateful_widget(list, chunks[1], &mut state);
let input_line = match &app.mode {
Mode::Normal => "a add/set d delete ↑/↓ select q quit".to_string(),
Mode::EditingId => format!("Secret id: {}", app.input),
Mode::EditingValue { id } => format!(
"Value for `{id}`: {}",
"".repeat(app.input.chars().count())
),
Mode::ConfirmDelete { id } => format!("Delete `{id}`? y/N"),
};
let help = Paragraph::new(vec![
Line::from(input_line),
Line::from(app.notice.clone()),
Line::from("Protection is local obfuscation at rest, not a system keychain."),
])
.block(Block::default().borders(Borders::ALL).title("Actions"))
.wrap(Wrap { trim: true });
frame.render_widget(Clear, chunks[2]);
frame.render_widget(help, chunks[2]);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_masks_value_and_emits_set_action() {
let mut app = KeysApp::new(vec![]);
assert_eq!(
app.handle_key(KeyCode::Char('a'), KeyModifiers::NONE),
Action::None
);
for c in "web/brave/test".chars() {
app.handle_key(KeyCode::Char(c), KeyModifiers::NONE);
}
assert_eq!(
app.handle_key(KeyCode::Enter, KeyModifiers::NONE),
Action::None
);
assert!(matches!(app.mode, Mode::EditingValue { .. }));
for c in "secret-value".chars() {
app.handle_key(KeyCode::Char(c), KeyModifiers::NONE);
}
assert_eq!(app.input, "secret-value");
let action = app.handle_key(KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(
action,
Action::Set {
id: "web/brave/test".into(),
value: "secret-value".into()
}
);
assert!(!format!("{app:?}").contains("secret-value"));
}
#[test]
fn model_confirms_delete() {
let mut app = KeysApp::new(vec!["providers/anthropic/default".into()]);
assert_eq!(
app.handle_key(KeyCode::Char('d'), KeyModifiers::NONE),
Action::None
);
let action = app.handle_key(KeyCode::Char('y'), KeyModifiers::NONE);
assert_eq!(
action,
Action::Delete {
id: "providers/anthropic/default".into()
}
);
}
}

View File

@ -3,6 +3,7 @@ mod block;
mod cache;
mod command;
mod input;
pub mod keys;
mod markdown;
mod multi_pod;
mod picker;

View File

@ -147,6 +147,6 @@ Pod が操作できるファイルパスの制御。
| Task | `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet` | セッション内の短期 task 状態管理 |
| Memory / Knowledge | `MemoryQuery`, `MemoryRead`, `MemoryWrite`, `MemoryEdit`, `MemoryDelete`, `KnowledgeQuery` | manifest の memory 設定が有効な時に登録される durable memory / knowledge 操作 |
| Pod orchestration | `SpawnPod`, `SendToPod`, `ReadPodOutput`, `StopPod`, `ListPods`, `RestorePod` | child / visible Pod の起動・通信・停止・一覧・復元 |
| Web | `WebSearch`, `WebFetch` | manifest/env で明示設定された provider 経由の bounded web access |
| Web | `WebSearch`, `WebFetch` | manifest で明示設定された provider と local secret-store reference 経由の bounded web access |
すべての tool call は manifest tool permission と scope/policy のチェックを通る。ファイル write scope、Pod delegation、memory layout、web provider 設定はそれぞれ別の authority を持ち、UI 表示だけで権限を広げない。

View File

@ -40,21 +40,30 @@ Builtin profiles and catalogs are embedded in the binary at build time. User/pro
## Credential と外部 auth
Provider credential は、現在は manifest / profile / catalog の設定から env var 名を明示的に参照できる。ただし、これは移行互換のための現状であり、長期的な supported configuration path ではない。`manifest-profile-encrypted-secrets` で encrypted secret store と typed secret reference を導入し、credential env var 依存は削除する方針である。
Provider API key と WebSearch credential は、通常の runtime では環境変数から読まない。`insomnia keys` で local secret store に論理 id を追加し、profile / manifest / provider catalog / web config がその id を明示的に参照する。
これは「ambient な provider 自動発見」ではなく、設定で選んだ環境変数名を読む仕組みである。通常 runtime が `.env` を暗黙に load することもない。
```toml
[model]
ref = "anthropic/claude-sonnet-4-6"
auth = { kind = "secret_ref", ref = "providers/anthropic/default" }
[web]
enabled = true
[web.search]
provider = "brave"
api_key_secret = "web/brave/default"
```
Store の user-visible schema は `id -> value` だけであり、store は provider 名や kind を解釈しない。自動的な provider-name-to-secret-id lookup は行わず、設定が secret id を選ぶ。
On-disk store は `<data_dir>/secrets/store.json`。secret value は軽量な obfuscation と integrity check で plaintext config / log / terminal output に出にくくするが、OS keychain や passphrase vault ではない。data directory と source code を読める local user に対する強い保護は主張しない。
| 変数 / pattern | 用途 | 備考 |
| --- | --- | --- |
| `INSOMNIA_API_KEY_ANTHROPIC` | custom env が指定されていない場合の Anthropic API key の default env 名。 | provider auth resolution で使う。 |
| `INSOMNIA_API_KEY_OPENAI` | OpenAI / OpenAI Responses API key の default env 名。 | provider auth resolution で使う。 |
| `INSOMNIA_API_KEY_GEMINI` | Gemini API key の default env 名。 | provider auth resolution で使う。 |
| `INSOMNIA_API_KEY_OPENROUTER` | builtin OpenRouter provider の auth hint。 | bundled provider catalog 由来。 |
| custom `model.auth.env` value | manifest / profile ごとの API key env 名。 | 明示的な config が変数名を選ぶ。`auth.env` と `auth.file` が両方ある場合は env が優先される。 |
| `BRAVE_SEARCH_API_KEY` または custom `web.search.api_key_env` | Brave WebSearch key。 | WebSearch は configured env 名だけを読み、missing / empty の場合は fail closed する。 |
| `CODEX_HOME` | Codex OAuth `auth.json` の場所。 | 外部互換用の入力。fallback は `$HOME/.codex`。 |
| `CODEX_HOME` | Codex OAuth `auth.json` の場所。 | 外部互換用の入力。fallback は `$HOME/.codex`。Codex OAuth は local secret store とは別の structured integration のまま維持する。 |
Credential env var は interoperability のために現時点では残っているが、長期的に望ましい secret mechanism ではない。現時点では適切なら `auth.file` を優先し、今後は typed secret reference へ寄せる。credential UX のために implicit `.env` loading を追加しないこと。project secret を漏らしやすく、profile ごとの credential model とも相性が悪い。
通常 runtime が `.env` を暗黙に load することはない。credential UX のために implicit `.env` loading を追加しないこと。
## Development-only escape hatches
@ -83,7 +92,7 @@ Credential env var は interoperability のために現時点では残ってい
1. fallback / precedence の test は、process environment を読ませず、直接入力を渡せる小さな pure helper で検証する。
2. path resolution は `manifest::paths` に集約し、path precedence rule を別の場所で重複実装しない。
3. test が process environment を変更するのは、process env から読む thin wrapper 自体を検証する場合や、subprocess isolation に必要な場合に限る。
4. credential source は resolved config 上で明示し、process-env convention を増やすより typed secret reference へ寄せる。encrypted secret store 導入時に credential env var 依存を削除する
4. credential source は resolved config 上で明示し、process-env convention を増やすより typed secret reference を使う
5. fallback env は独立した設定項目として増やさず、対応する main key の解決順として文書化する。
6. 空の env value は、変数 category に応じて unset / invalid のどちらとして扱うかを一貫させ、新しい supported variable を追加する場合は挙動を文書化する。
7. 外部 process integration が env inheritance / filtering を必要とする場合は、ambient な inherited process state に頼らず、明示的な policy boundary として設計する。

View File

@ -2,7 +2,7 @@
Profiles are reusable Lua-authored recipes for generating an Insomnia runtime manifest. The Rust resolver evaluates a selected `.lua` profile in-process, validates that it is Profile-shaped rather than a complete Manifest, then binds runtime values such as Pod name and concrete scope to produce the persisted `PodManifest` snapshot.
Profiles are intentionally not authority-bearing manifests. `pod.name`, concrete `scope.allow` / `scope.deny`, runtime directories, sockets, active session state, and raw secret material do not belong in reusable profiles. Use `--manifest` when you need the explicit low-level complete Manifest escape hatch.
Profiles are intentionally not authority-bearing manifests. `pod.name`, concrete `scope.allow` / `scope.deny`, runtime directories, sockets, active session state, and raw secret material do not belong in reusable profiles. Use `insomnia keys` to store provider/WebSearch credentials, then reference explicit secret ids such as `auth = { kind = "secret_ref", ref = "providers/anthropic/default" }` or `web.search.api_key_secret = "web/brave/default"`. Use `--manifest` when you need the explicit low-level complete Manifest escape hatch.
## Minimal profile

View File

@ -63,18 +63,16 @@ ref = "anthropic/claude-sonnet-4-6"
# base_url = "https://api.anthropic.com"
# 任意 (ref 未指定時は実質必須)。デフォルト: なし。
# kind の値: "none" | "api_key" | "codex_oauth"
# kind の値: "none" | "secret_ref" | "api_key" | "codex_oauth"
# - "none" … 認証不要 (ローカル Ollama 等)
# - "api_key" … env / file のいずれかで key を渡す。両方指定なら env 優先。
# env 未指定時は scheme ごとの既定環境変数:
# Anthropic -> INSOMNIA_API_KEY_ANTHROPIC
# OpenaiChat / OpenaiResponses -> INSOMNIA_API_KEY_OPENAI
# Gemini -> INSOMNIA_API_KEY_GEMINI
# file 指定時、相対パスは manifest base 起点で解決。
# - "secret_ref" … `insomnia keys` の local secret store から key を読む。
# `ref` はユーザー設定が明示的に選ぶ論理 secret id。
# store は id -> value のみを持ち、provider 種別を解釈しない。
# - "api_key" … 明示ファイルから key を読む低レベル形式。通常は
# `secret_ref` を使う。file 指定時、相対パスは manifest base 起点で解決。
# - "codex_oauth" … ChatGPT OAuth (`~/.codex/auth.json`)。追加フィールドなし。
# auth = { kind = "none" }
# auth = { kind = "api_key" } # env のみ既定使用
# auth = { kind = "api_key", env = "MY_ANTHROPIC_KEY" }
# auth = { kind = "secret_ref", ref = "providers/anthropic/default" }
# auth = { kind = "api_key", file = "./sk-ant.local" }
# auth = { kind = "codex_oauth" }

View File

@ -36,7 +36,7 @@ return profile {
enabled = true,
search = {
provider = "brave",
api_key_env = "BRAVE_SEARCH_API_KEY",
api_key_secret = "web/brave/default",
},
},
}

View File

@ -0,0 +1,10 @@
---
## Pod orchestration
When Pod-management tools are available, spawned Pod notifications are background signals for the parent to handle at a natural stopping point. Do not ignore routine follow-up, but do not interrupt the current user request unnecessarily.
The parent does not need to keep a turn open or call tools solely to wait for a notification. Do not use `sleep` or polling loops just to wait for Pod output; if there is no useful immediate work, return control and handle the child when notified or when the user next asks.
Before treating delegated work as complete, read the child output and inspect concrete evidence such as worktree status, diff, and test results. Notifications are hints, not proof of completion.
This guidance is not scheduler or auto-maintain authorization. Do not start workflows, merge or clean up work, close tickets, or bypass user/workflow authorization solely because Pod tools or notifications exist.

View File

@ -5,10 +5,20 @@ When searching, use grep/glob primitives rather than shell pipelines.
You can run multiple tools simultaneously by calling them within a single response.
It is recommended to run tools that handle asynchronous processing, such as queries and readings, in batches.
{% if tool_capabilities.memory_any %}
### Memory and knowledge
For past decisions, prior requests, durable preferences, project history, or why something was done, use targeted lookup instead of guessing from vague recollection.
Use `MemoryQuery` for durable memory records (summary, decisions, requests), `KnowledgeQuery` for project knowledge, `MemoryRead(kind=summary)` for the full memory summary, and `MemoryRead` on returned slugs when excerpts are insufficient.
Resident memory and knowledge are helpful context but may be stale; current user instructions, repository files, tickets, git history, and session logs are authoritative for exact current state.
Do not query memory every turn, and normally prefer read/query tools; use `MemoryWrite`, `MemoryEdit`, or `MemoryDelete` only when explicitly asked or in a memory maintenance worker.
{% if tool_capabilities.memory_records and tool_capabilities.knowledge_query %}Use memory and knowledge proactively{% elif tool_capabilities.memory_records %}Use memory proactively{% else %}Use knowledge proactively{% endif %} when the request may depend on prior project decisions, historical rationale, durable user preferences, recently completed tickets, or established workflow/policy conventions.
{% if tool_capabilities.memory_query and tool_capabilities.knowledge_query %}Prefer a small targeted `MemoryQuery` / `KnowledgeQuery` before relying on vague recollection.
{% elif tool_capabilities.memory_query %}Prefer a small targeted `MemoryQuery` before relying on vague recollection.
{% elif tool_capabilities.knowledge_query %}Prefer a small targeted `KnowledgeQuery` before relying on vague recollection.
{% endif %}
Strong lookup triggers include: the user says "recently", "previously", "that decision", "the ticket", "why", "policy", or "workflow"; you are about to make a design recommendation; you are reviewing, merging, closing, or rescoping a work item; or you are about to assert project history from memory.
{% if tool_capabilities.memory_read %}
Use `MemoryRead(kind=summary)` for the full memory summary, and `MemoryRead` on returned slugs when excerpts are insufficient.
{% endif %}
{% if tool_capabilities.memory_records and tool_capabilities.knowledge_query %}Resident memory and knowledge are{% elif tool_capabilities.knowledge_query %}Resident knowledge is{% else %}Resident memory is{% endif %} helpful context but may be stale; current user instructions, repository files, tickets, git history, and session logs are authoritative for exact current state.
Do not query memory every turn or mechanically. Skip memory lookup for purely local facts answered by current repository files, command output, or current user instructions.
{% if tool_capabilities.memory_mutation %}Normally prefer read/query tools; use available mutation tools ({% if tool_capabilities.memory_write %}`MemoryWrite`{% endif %}{% if tool_capabilities.memory_edit %}{% if tool_capabilities.memory_write %}, {% endif %}`MemoryEdit`{% endif %}{% if tool_capabilities.memory_delete %}{% if tool_capabilities.memory_write or tool_capabilities.memory_edit %}, {% endif %}`MemoryDelete`{% endif %}) only when explicitly asked or in a memory maintenance worker.
{% endif %}{% endif %}

View File

@ -52,7 +52,7 @@ resident_knowledge_section = """\
---
## Resident knowledge
The following knowledge records are advertised resident. Use the KnowledgeQuery / MemoryRead tools to fetch the full body when relevant.
The following knowledge records are advertised resident.{% if knowledge_query_available and memory_read_available %} Use the KnowledgeQuery / MemoryRead tools to fetch the full body when relevant.{% elif knowledge_query_available %} Use KnowledgeQuery to search related knowledge records when relevant.{% elif memory_read_available %} Use MemoryRead on a known knowledge slug when the full body is required.{% endif %}
{{ entries }}\
"""
@ -66,6 +66,8 @@ The following workflows are advertised resident. When a user request matches one
{{ entries }}\
"""
pod_orchestration_guidance_section = "{% include \"$insomnia/common/pod-orchestration\" %}"
spawn_pod_tool_description = """\
Spawn a new Pod process to work on a delegated task. The spawner's write scope is reduced by the scope passed here; the spawned Pod receives its own socket and starts running `task` immediately. The spawned Pod outlives the spawner's current turn and can be contacted again through its socket path.

View File

@ -3,7 +3,7 @@ id = "anthropic"
display_name = "Anthropic"
scheme = "anthropic"
base_url = "https://api.anthropic.com"
auth_hint = { kind = "api_key", env = "INSOMNIA_API_KEY_ANTHROPIC" }
auth_hint = { kind = "secret_ref", ref = "providers/anthropic/default" }
default_capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "budget_tokens", vision = true, prompt_caching = { kind = "explicit", max_breakpoints = 4 } }
default_context_window = 200000
@ -29,6 +29,6 @@ id = "openrouter"
display_name = "OpenRouter"
scheme = "openai_chat"
base_url = "https://openrouter.ai/api/v1"
auth_hint = { kind = "api_key", env = "INSOMNIA_API_KEY_OPENROUTER" }
auth_hint = { kind = "secret_ref", ref = "providers/openrouter/default" }
default_capability = { tool_calling = "parallel", structured_output = "json_schema", vision = true, prompt_caching = { kind = "auto" } }
default_context_window = 200000

View File

@ -2,12 +2,12 @@
id: 20260527-000021-bash-tool-editing-guidance
slug: bash-tool-editing-guidance
title: Bashツールがファイル編集に常用されている問題をdesciptionで抑制
status: open
status: closed
kind: task
priority: P2
labels: [migrated]
created_at: 2026-05-27T00:00:21Z
updated_at: 2026-05-27T00:00:21Z
updated_at: 2026-05-31T22:36:34Z
assignee: null
legacy_ticket: null
---

View File

@ -0,0 +1 @@
Closed without implementation for now. Current Bash tool description already nudges agents toward Read/Edit/Glob/Grep over shell-based file edits, and this is not urgent enough to carry as an active work item. If the behavior becomes a recurring problem, reopen as a focused prompt-description polish ticket covering Bash child processes such as cat/tee/sed/perl/python rewrites.

View File

@ -0,0 +1,16 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:21Z -->
## Migrated
Migrated from TODO.md entry without a legacy ticket file. No legacy review file was present at migration time.
---
<!-- event: close author: hare at: 2026-05-31T22:36:34Z status: closed -->
## Closed
Closed without implementation for now. Current Bash tool description already nudges agents toward Read/Edit/Glob/Grep over shell-based file edits, and this is not urgent enough to carry as an active work item. If the behavior becomes a recurring problem, reopen as a focused prompt-description polish ticket covering Bash child processes such as cat/tee/sed/perl/python rewrites.
---

View File

@ -0,0 +1,35 @@
---
id: 20260527-194421-pod-orchestration-system-guidance
slug: pod-orchestration-system-guidance
title: Pod orchestration tool availability に応じた system guidance
status: closed
kind: feature
priority: P2
labels: [pod, workflow, prompt]
created_at: 2026-05-27T19:44:21Z
updated_at: 2026-06-01T01:24:59Z
assignee: null
legacy_ticket: null
---
## Background
Child Pod completion/status notifications are delivered as non-blocking background signals. Parent Pods that have Pod management tools should treat notifications for Pods they spawned as actionable orchestration state, but should not block the active turn merely to wait for output.
Current guidance is too weak: agents may either ignore routine child-Pod follow-up until the user asks, or waste a turn with `sleep`/polling while waiting for a notification. The desired behavior is notification-driven follow-up at a natural stopping point.
Prompt text belongs under `resources/prompts`; Rust code should only assemble it conditionally.
## Acceptance criteria
- Pod management toolsが有効な Worker の system prompt に orchestration guidance が含まれる。
- Pod management tools が無効な Worker には含まれない。
- prompt 本文が `resources/prompts` にある。
- guidance includes:
- spawned Pod notifications are background signals the parent should handle at a natural stopping point;
- the parent does not need to keep a turn open or call tools solely to wait for a notification;
- do not use `sleep`/polling loops just to wait for Pod output;
- read child output/diff/test evidence before treating delegated work as complete;
- do not start scheduler/auto-maintain behavior or bypass user/workflow authorization.
- Prompt assembly tests cover conditional inclusion/exclusion.
- Related focused tests and `cargo fmt --check` pass.

View File

@ -0,0 +1,18 @@
Merged and completed.
Implementation:
- Added resource-backed Pod orchestration guidance at `resources/prompts/common/pod-orchestration.md`.
- Registered the guidance through the prompt catalog and internal prompt resources.
- Added conditional system prompt assembly based on registered Pod-management tool names.
- Guidance is included for Workers with Pod management tools and omitted otherwise.
- Guidance explicitly says Pod notifications are background signals handled at natural stopping points, that the parent does not need to keep a turn open solely to wait, and that agents should not use `sleep`/polling loops just to wait for Pod output.
- Guidance also preserves evidence-before-completion and no scheduler/authorization-bypass constraints.
Review:
- External reviewer approved with no blockers.
Validation after merge:
- `cargo test -p pod pod_orchestration` passed.
- `cargo test -p pod prompt::catalog` passed.
- `cargo fmt --check` passed.
- `./tickets.sh doctor` passed.

View File

@ -0,0 +1,177 @@
<!-- event: create author: tickets.sh at: 2026-05-27T19:44:21Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: orchestrator at: 2026-05-27T19:44:43Z -->
## Plan
## Background
Pod notification / notice によって child Pod の完了や状態変化が見えても、現状の assistant はユーザーから明示的に「レビューして」「確認して」と言われるまで自発的に消化しないことがある。
AGENTS.md や workflow に multi-agent の運用は書かれているが、これは知識として読めるだけで、Pod 管理ツールが利用可能な turn における runtime 行動規範としては弱い。特に、自分が spawn した child Pod の完了通知は background signal として扱い、自然な区切りで `ReadPodOutput` / worktree status / diff / test を確認して次の action に進むべきである。
一方で、notification は non-blocking であり、進行中の user request を不必要に中断してまで消化すべきではない。system instruction には「自発的に follow-up するが、現在の user task を壊さない」というバランスを明示する必要がある。
## Requirements
- Pod management tools が有効な Worker にだけ、Pod orchestration 用の system guidance を注入する。
- 例: `SpawnPod` / `ReadPodOutput` / `SendToPod` / `StopPod` / `AttachOrRestorePod` などが利用可能な場合。
- Pod 管理 tool がない通常 Worker / child Pod には不要な guidance を出さない。
- guidance 本文は `resources/prompts` 配下に置く。
- prompt 文字列を Rust code に直書きしない。
- guidance には以下を含める。
- Pod notification / notice は、自分が処理すべき background signal として扱う。
- 自分が spawn した child Pod の完了通知を受けたら、自然な区切りで `ReadPodOutput` を確認する。
- 委譲 task が完了していれば、報告・worktree status・diff・test 結果を確認し、修正依頼 / merge / ticket 完了処理 / Pod 停止のいずれかに進む。
- user が明示的に follow-up を要求するまで routine follow-up を放置しない。
- ただし進行中の user request を不用意に中断しない。
- output / diff / test を確認せずに完了扱いしない。
- この guidance は scheduler / auto-maintainer ではない。
- workflow を勝手に開始しない。
- project decision / merge / cleanup は既存 workflow と user authorization に従う。
- notification / PodEvent を context に載せる場合は、既存の history 永続化原則を破らない。
- turn を跨げない情報を history に残さず system context にだけ差し込まない。
## Acceptance criteria
- Pod management tools が有効な Worker の system prompt に orchestration guidance が含まれる。
- Pod management tools が無効な Worker には含まれない。
- prompt 本文が `resources/prompts` にある。
- prompt assembly の test で conditional inclusion が確認されている。
- guidance が user request の中断を促さず、natural stopping point での follow-up を促す文言になっている。
- `cargo fmt --check` と関連 crate の test が通る。
## Out of scope
- 自動 scheduler / auto-maintain loop の実装。
- PodEvent / notification の protocol 変更。
- spawned Pod registry restore の修正。
- TUI notification UI の変更。
---
<!-- event: comment author: hare at: 2026-06-01T00:57:03Z -->
## Comment
## Supplemental guidance from dogfooding
Add two explicit rules to the Pod orchestration/system guidance:
- A spawned Pod completion notification is delivered as a normal background signal. The parent does not need to keep the turn open or call tools solely to wait for it; it is acceptable to finish the current turn and handle the notification at the next natural point.
- Do not use `sleep`/polling loops just to wait for a Pod's output. If there is no other useful immediate work, return control to the user instead of blocking the turn; when the notification arrives, read the Pod output then.
Rationale: during multi-agent work, waiting with `sleep` wastes the active turn and fights the notification model. The desired behavior is notification-driven follow-up, not artificial polling.
---
<!-- event: plan author: hare at: 2026-06-01T01:10:57Z -->
## Plan
## Preflight classification
implementation-ready.
The ticket affects prompt/system guidance and conditional prompt assembly, but the desired product behavior is already specified in the ticket thread and item: include orchestration guidance only when Pod management tools are available, keep the prose in `resources/prompts`, and explicitly avoid `sleep`/polling or turn-blocking waits for child Pod output.
## Current code map
- `resources/prompts/`: prompt text sources; new guidance text should live here.
- Prompt assembly code/tests: locate the system prompt construction path that already conditionally includes memory/workflow/tool guidance and add a tool-availability gate for Pod orchestration guidance.
- Tool registry / available tool list: use existing tool availability rather than hard-coding a Worker kind if possible.
- Existing prompt assembly tests: add inclusion/exclusion coverage for Pod management tools available/unavailable.
## Requirements / invariants
- Guidance is conditional on Pod management tools being available.
- Guidance is not shown to Workers without Pod management tools.
- Guidance must not imply an auto scheduler or unauthorized workflow start.
- Guidance must say notifications can be handled at the next natural point and the parent need not keep the turn open just to wait.
- Guidance must say not to use `sleep`/polling loops merely to wait for Pod output.
- Do not change PodEvent/notification protocol, TUI notification UI, spawned registry restore, or workflow semantics.
## Escalate if
- The only available hook requires injecting notification-derived context without durable history.
- Conditional tool-availability detection would require broad ToolRegistry redesign.
- The implementation would change runtime notification delivery or Pod lifecycle behavior instead of prompt guidance.
## Validation
- Focused prompt assembly tests for conditional inclusion/exclusion.
- Any touched crate tests relevant to prompt loading/assembly.
- `cargo fmt --check`.
---
<!-- event: review author: hare at: 2026-06-01T01:24:27Z status: approve -->
## Review: approve
External reviewer recommendation: approve.
Summary:
- Static Pod orchestration guidance was added under `resources/prompts/common/pod-orchestration.md` and registered through the prompt catalog.
- The guidance is appended to the materialized system prompt only when available tool names include Pod-management capabilities.
- The gate uses registered tool definitions, not Worker kind, matching the ticket boundary.
- The prompt explicitly covers background notifications, natural stopping points, not keeping a turn open solely to wait, no `sleep`/polling loops for Pod output, evidence-before-completion, and no scheduler/authorization bypass.
Intent / requirement mapping:
- Included when Pod management tools are enabled: satisfied.
- Omitted when Pod management tools are disabled: satisfied.
- Prompt body lives under `resources/prompts`: satisfied.
- Conditional prompt assembly tests exist: satisfied.
Invariant check:
- No changes to PodEvent/notification protocol, TUI notification UI, spawned registry restore, Pod lifecycle behavior, scheduler/auto-maintain behavior, or notification-derived context injection.
- The implementation adds static guidance based on durable tool availability, not transient notification state.
Blockers: none.
Non-blocking follow-ups:
- A future small test could pin the intended "any Pod-management tool is enough" semantics with a single representative tool.
- Tool-name class recognition could be centralized later if more prompt gates need it.
Reported validation from coder was considered sufficient:
- `cargo test -p pod pod_orchestration`
- `cargo test -p pod prompt::catalog`
- `cargo fmt --check`
---
<!-- event: close author: hare at: 2026-06-01T01:24:59Z status: closed -->
## Closed
Merged and completed.
Implementation:
- Added resource-backed Pod orchestration guidance at `resources/prompts/common/pod-orchestration.md`.
- Registered the guidance through the prompt catalog and internal prompt resources.
- Added conditional system prompt assembly based on registered Pod-management tool names.
- Guidance is included for Workers with Pod management tools and omitted otherwise.
- Guidance explicitly says Pod notifications are background signals handled at natural stopping points, that the parent does not need to keep a turn open solely to wait, and that agents should not use `sleep`/polling loops just to wait for Pod output.
- Guidance also preserves evidence-before-completion and no scheduler/authorization-bypass constraints.
Review:
- External reviewer approved with no blockers.
Validation after merge:
- `cargo test -p pod pod_orchestration` passed.
- `cargo test -p pod prompt::catalog` passed.
- `cargo fmt --check` passed.
- `./tickets.sh doctor` passed.
---

View File

@ -0,0 +1,46 @@
Implemented and merged local key-value secret store support.
Merged commits:
- `cc2c9a2 secrets: add local key store`
- `7ddf745 secrets: polish key manager and docs`
- `629159a merge: local secret store`
Review:
- Review approved in `c9e48b3 review: approve local secret store`.
- Focused follow-up review approved the docs example and key-manager terminal cleanup polish.
Summary:
- Added a provider-independent local `id -> value` secret store under the user data directory.
- Added id validation, atomic persistence, and lightweight at-rest obfuscation consistent with the ticket's modest security target.
- Added `insomnia keys` interactive TUI management for listing ids, setting values with masked display, deleting with confirmation, and quitting without displaying plaintext values.
- Wired provider `secret_ref` auth through the store.
- Added WebSearch `api_key_secret` and removed normal WebSearch/provider credential env configuration.
- Updated bundled resources and docs to point users to `insomnia keys` plus explicit secret refs.
- Left Codex OAuth behavior unchanged.
Validation after merge:
- `cargo fmt --check` — passed
- `cargo test -p secrets` — passed
- `cargo test -p manifest secret --lib` — passed
- `cargo test -p provider secret --lib` — passed
- `cargo test -p tools web::tests::search_requires_configuration --lib` — passed
- `cargo test -p tools web::tests::searches_brave_with_secret_ref --lib` — passed
- `cargo test -p tools web::tests::searches_brave_with_bounded_output --lib` — passed
- `cargo test -p tui keys::tests --lib` — passed
- `cargo test -p insomnia parse_keys_subcommand --bin insomnia` — passed
- `cargo check -p manifest -p provider -p tools -p tui -p insomnia` — passed
- `./tickets.sh doctor` — passed
- `git diff --check` — passed
Credential/env grep:
- `api_key_env`, `BRAVE_SEARCH_API_KEY`, `INSOMNIA_API_KEY`, and `default_env_var` are absent from `crates docs resources` after the merge.
- Remaining `sk-`/`secret-value`/`test-secret` hits are fake test values, docs/comments, or Codex OAuth test fixtures, not new persisted real credentials.
Caveat:
- The store should continue to be described as local obfuscation / limited at-rest protection, not a high-assurance password manager.

View File

@ -0,0 +1,34 @@
# Review: local key-value secret store implementation
Implementation reviewed on branch `manifest-profile-encrypted-secrets`.
Reviewed commits:
- `cc2c9a2 secrets: add local key store`
- `7ddf745 secrets: polish key manager and docs`
Verdict: approve.
Summary:
- Core provider-independent `id -> value` local secret store satisfies the ticket model.
- Store values are not persisted as casual plaintext and error/debug surfaces avoid secret values within the stated modest protection boundary.
- Provider auth now resolves explicit `secret_ref` values through the local store without env credential fallback.
- WebSearch uses explicit `api_key_secret` and no longer depends on `BRAVE_SEARCH_API_KEY` / `api_key_env` in the normal path.
- `insomnia keys` provides interactive list/add-set/delete management without displaying plaintext values.
- Codex OAuth behavior remains separate and unchanged.
- Follow-up review confirmed the docs credential example is schema-valid and key-manager terminal setup cleanup was added.
Validation reported by coder/reviewer:
- `cargo fmt --check`
- `cargo test -p secrets`
- focused manifest/provider/tools/tui/insomnia tests
- `cargo check -p manifest -p provider -p tools -p tui -p insomnia`
- `./tickets.sh doctor`
- `git diff --check`
- credential/env greps confirming `api_key_env`, `BRAVE_SEARCH_API_KEY`, `INSOMNIA_API_KEY`, and `default_env_var` are absent from crates/docs/resources
Remaining caveat:
- Continue to describe this as local obfuscation / limited at-rest protection, not a high-assurance password manager or OS-keychain-backed vault.

View File

@ -2,12 +2,12 @@
id: 20260529-145355-manifest-profile-encrypted-secrets
slug: manifest-profile-encrypted-secrets
title: Manifest/Profile: local key-value secret store
status: open
status: closed
kind: feature
priority: P2
labels: [manifest, profiles, secrets, security, cli, tui]
created_at: 2026-05-29T14:53:55Z
updated_at: 2026-05-31T21:23:46Z
updated_at: 2026-05-31T22:23:34Z
assignee: null
legacy_ticket: null
---

View File

@ -203,4 +203,46 @@ Codex OAuth relationship:
- If Insomnia later owns Codex login/token storage, design it as a separate OAuth token-store feature, not as an implicit use of the simple key-value store.
---
<!-- event: review author: hare at: 2026-05-31T22:21:04Z status: approve -->
## Review: approve
# Review: local key-value secret store implementation
Implementation reviewed on branch `manifest-profile-encrypted-secrets`.
Reviewed commits:
- `cc2c9a2 secrets: add local key store`
- `7ddf745 secrets: polish key manager and docs`
Verdict: approve.
Summary:
- Core provider-independent `id -> value` local secret store satisfies the ticket model.
- Store values are not persisted as casual plaintext and error/debug surfaces avoid secret values within the stated modest protection boundary.
- Provider auth now resolves explicit `secret_ref` values through the local store without env credential fallback.
- WebSearch uses explicit `api_key_secret` and no longer depends on `BRAVE_SEARCH_API_KEY` / `api_key_env` in the normal path.
- `insomnia keys` provides interactive list/add-set/delete management without displaying plaintext values.
- Codex OAuth behavior remains separate and unchanged.
- Follow-up review confirmed the docs credential example is schema-valid and key-manager terminal setup cleanup was added.
Validation reported by coder/reviewer:
- `cargo fmt --check`
- `cargo test -p secrets`
- focused manifest/provider/tools/tui/insomnia tests
- `cargo check -p manifest -p provider -p tools -p tui -p insomnia`
- `./tickets.sh doctor`
- `git diff --check`
- credential/env greps confirming `api_key_env`, `BRAVE_SEARCH_API_KEY`, `INSOMNIA_API_KEY`, and `default_env_var` are absent from crates/docs/resources
Remaining caveat:
- Continue to describe this as local obfuscation / limited at-rest protection, not a high-assurance password manager or OS-keychain-backed vault.
---

View File

@ -0,0 +1,32 @@
Implemented and merged conditional memory prompt guidance.
Merged commits:
- `03256db memory: gate prompt guidance`
- `3a0c8e1 memory: clean prompt helper warning`
- `bdb52b1 merge: memory prompt guidance`
Review:
- Review approved in `62d88d9 review: approve memory prompt guidance`.
- Initial review requested one warning cleanup; follow-up review approved the fix.
Summary:
- Normal prompt memory/knowledge guidance is now gated by available tool capabilities.
- Memory-disabled prompts do not advertise `MemoryQuery`, `KnowledgeQuery`, `MemoryRead`, or memory mutation tools.
- Partial-capability prompts avoid naming unavailable memory vs knowledge tools.
- Normal Pod guidance now encourages somewhat more proactive small targeted lookups when prior decisions, rationale, durable preferences, tickets, policy, workflow, or project history may matter.
- Existing cautions remain: memory can be stale; current authoritative state lives in user instructions, repository files, tickets, git history, and session logs; memory should not be queried mechanically every turn; mutation tools remain restricted.
- Internal memory extraction/consolidation prompts remain focused on their worker roles and do not inherit normal Pod guidance.
Validation after merge:
- `cargo fmt --check` — passed
- `cargo test -p pod prompt::` — passed (`53 passed; 0 failed`)
- `./tickets.sh doctor` — passed
- `git diff --check` — passed
Caveats:
- The test run still reports an existing unrelated `llm-worker` dead-code warning; the new prompt helper warning found during review was removed.

View File

@ -0,0 +1,27 @@
# Review: memory prompt conditional lookup
Implementation reviewed on branch `memory-prompt-conditional-lookup`.
Reviewed commits:
- `03256db memory: gate prompt guidance`
- `3a0c8e1 memory: clean prompt helper warning`
Verdict: approve.
Summary:
- Prompt rendering now gates normal memory/knowledge guidance according to available tool capabilities.
- Memory-disabled prompt rendering does not advertise memory tools.
- Partial-capability rendering avoids naming unavailable memory or knowledge tools.
- Normal Pod guidance now encourages somewhat more proactive small targeted lookup when prior decisions, rationale, durable preferences, tickets, policy, workflow, or project history may affect the response or action.
- Existing cautions remain: memory can be stale; current authoritative state lives in user instructions, repository files, tickets, git history, and session logs; memory should not be queried mechanically every turn; mutation tools are restricted.
- Internal memory extraction/consolidation worker prompts remain isolated from normal Pod guidance.
Review notes:
- Initial review requested one blocker fix because `cargo test -p pod prompt::` emitted a new unused `append_trailing_section` warning.
- Follow-up commit `3a0c8e1` removed the warning-producing helper path and adjusted partial-capability wording.
- Final focused review approved the follow-up. `cargo test -p pod prompt::` passed with `53 passed; 0 failed`; only an existing unrelated `llm-worker` dead-code warning remains.
Merge readiness: ready to merge.

View File

@ -0,0 +1,74 @@
---
id: 20260531-223506-memory-prompt-conditional-lookup
slug: memory-prompt-conditional-lookup
title: Memory prompt: conditional guidance and proactive lookup
status: closed
kind: task
priority: P2
labels: [memory, prompts, tools]
created_at: 2026-05-31T22:35:06Z
updated_at: 2026-05-31T22:52:35Z
assignee: null
legacy_ticket: null
---
## Background
The normal tool-usage prompt currently contains memory guidance unconditionally:
- use targeted lookup for past decisions, prior requests, durable preferences, project history, or rationale;
- use `MemoryQuery`, `KnowledgeQuery`, and `MemoryRead` appropriately;
- treat resident memory/knowledge as helpful but stale;
- avoid querying memory every turn;
- avoid mutation unless explicitly requested or running a memory maintenance worker.
This is directionally correct, but two prompt-level issues remain:
1. Memory/Knowledge guidance should be rendered only when the corresponding memory capability is enabled and available. If memory is disabled for a Pod/profile/internal worker, the prompt should not advertise tools or behavior that cannot be used.
2. The read-side guidance is too passive. Agents should be encouraged to use small targeted queries more often when prior project decisions, user preferences, historical rationale, recent ticket work, or policy/workflow context may affect the answer or action.
This should be implemented as prompt/template behavior, not by weakening tool permissions or making memory lookup automatic.
## Requirements
- Gate memory/knowledge prompt text through the prompt/template engine based on whether memory/knowledge tools or resident memory features are enabled for the current worker.
- Do not show instructions for unavailable memory tools.
- Keep internal/disposable worker behavior conservative; do not accidentally inject normal Pod memory guidance into memory extraction/consolidation workers or other internal workers that should not see it.
- Strengthen normal Pod read-side guidance so agents perform small targeted `MemoryQuery` / `KnowledgeQuery` lookups more proactively when history may matter.
- Preserve the existing warnings:
- memory may be stale;
- authoritative current state is in user instructions, repository files, tickets, git history, and session logs;
- do not query memory mechanically every turn;
- mutation tools are for explicit user requests or memory maintenance workers.
- Do not introduce automatic pre-request memory queries in this ticket.
- Do not change memory extraction/consolidation semantics except as needed to keep prompt rendering coherent.
## Suggested prompt direction
The final wording does not need to match this exactly, but should convey:
```text
Use memory and knowledge proactively when the request may depend on prior
project decisions, historical rationale, durable user preferences, recently
completed tickets, or established workflow/policy conventions. Prefer a small
targeted MemoryQuery/KnowledgeQuery before relying on vague recollection.
Strong lookup triggers include: the user says "recently", "previously", "that
decision", "the ticket", "why", "policy", or "workflow"; you are about to make
a design recommendation; you are reviewing, merging, closing, or rescoping a
work item; or you are about to assert project history from memory.
Do not query memory mechanically on every turn. Skip memory lookup for purely
local facts answered by current repository files, command output, or current
user instructions.
```
## Acceptance criteria
- Normal Pod prompts include memory/knowledge guidance only when the corresponding capability is enabled and available.
- Prompts for memory-disabled profiles/workers do not advertise `MemoryQuery`, `KnowledgeQuery`, `MemoryRead`, or memory mutation tools.
- Normal Pod memory guidance explicitly encourages somewhat more frequent small targeted queries when history, rationale, preferences, tickets, policy, workflow, or prior decisions may matter.
- Existing stale/authority cautions and no-mechanical-query guidance remain.
- Internal memory extraction/consolidation prompts remain focused on their worker roles and do not inherit normal Pod guidance accidentally.
- Tests or focused snapshots cover enabled vs disabled prompt rendering, or the implementation otherwise includes a clear focused regression test for conditional rendering.
- `cargo fmt --check`, relevant prompt/worker tests, `./tickets.sh doctor`, and `git diff --check` pass.

View File

@ -0,0 +1,42 @@
<!-- event: create author: tickets.sh at: 2026-05-31T22:35:06Z -->
## Created
Created by tickets.sh create.
---
<!-- event: review author: hare at: 2026-05-31T22:51:36Z status: approve -->
## Review: approve
# Review: memory prompt conditional lookup
Implementation reviewed on branch `memory-prompt-conditional-lookup`.
Reviewed commits:
- `03256db memory: gate prompt guidance`
- `3a0c8e1 memory: clean prompt helper warning`
Verdict: approve.
Summary:
- Prompt rendering now gates normal memory/knowledge guidance according to available tool capabilities.
- Memory-disabled prompt rendering does not advertise memory tools.
- Partial-capability rendering avoids naming unavailable memory or knowledge tools.
- Normal Pod guidance now encourages somewhat more proactive small targeted lookup when prior decisions, rationale, durable preferences, tickets, policy, workflow, or project history may affect the response or action.
- Existing cautions remain: memory can be stale; current authoritative state lives in user instructions, repository files, tickets, git history, and session logs; memory should not be queried mechanically every turn; mutation tools are restricted.
- Internal memory extraction/consolidation worker prompts remain isolated from normal Pod guidance.
Review notes:
- Initial review requested one blocker fix because `cargo test -p pod prompt::` emitted a new unused `append_trailing_section` warning.
- Follow-up commit `3a0c8e1` removed the warning-producing helper path and adjusted partial-capability wording.
- Final focused review approved the follow-up. `cargo test -p pod prompt::` passed with `53 passed; 0 failed`; only an existing unrelated `llm-worker` dead-code warning remains.
Merge readiness: ready to merge.
---

View File

@ -0,0 +1,29 @@
---
id: 20260601-001616-prompt-occupancy-token-estimator
slug: prompt-occupancy-token-estimator
title: Token estimator must keep prompt occupancy accounting whole
status: closed
kind: task
priority: P1
labels: [compaction, token-accounting]
created_at: 2026-06-01T00:16:16Z
updated_at: 2026-06-01T01:10:06Z
assignee: null
legacy_ticket: null
---
## Background
New sessions can compact on the first turn even when the actual request does not exceed the configured compact thresholds. A representative session showed the first measured request at `history_len=1` with `input_total_tokens=11124`, then a mid-turn `run_completed` with `result="yielded"`, followed by a new segment with `compacted_from.at_turn_index=1`.
The suspected cause is token accounting that combines unlike properties: provider `input_total_tokens` measures the whole prompt occupancy, while current estimator paths use only history serialization bytes as the denominator. This effectively treats system/developer/tool schema/resident memory overhead as if it belonged to the history prefix, so first-turn history growth can be overestimated and trip `request_threshold`.
The fix should keep compact/request-threshold accounting focused on whole-request prompt occupancy instead of splitting system and history into a false exact model. Prune behavior is not in scope for this ticket; prune metrics may appear in the same logs but are not the cause of the first-turn compact.
## Acceptance criteria
- Compact/request-threshold estimation pairs measured `input_total_tokens` with bytes or another size measure for the same full request shape, not history-only bytes.
- Exact usage records are treated as authoritative for the measured request occupancy at their recorded request shape/prefix.
- Unmeasured request occupancy extrapolation no longer applies `total_input_tokens / history_bytes`.
- A regression test covers a fresh session / one prior usage record case where fixed prompt overhead is large and first-turn tool history growth must not trigger compact solely from the old overestimation.
- Session/log diagnostics remain sufficient to distinguish prune activity from compact/yield activity when investigating threshold behavior.

View File

@ -0,0 +1,15 @@
Merged and completed.
Implementation:
- Merged branch `prompt-occupancy-token-estimator` into `develop` with `merge: prompt occupancy estimator`.
- `llm-worker` token counter extrapolation now keeps exact measured prompt occupancy authoritative and no longer extrapolates one-measurement growth via `total_input_tokens / history_bytes`.
- Extrapolation past the latest measurement uses a measured incremental span rate when available; otherwise it adds a conservative byte fallback for the unmeasured delta.
- Added pod interceptor regression coverage for the fresh-session / one-measurement overestimation case.
Validation after merge:
- `cargo test -p llm-worker token_counter` passed.
- `cargo test -p pod pre_llm_request_does_not_yield_from_single_measurement_history_rate_projection` passed.
- `./tickets.sh doctor` passed.
Review:
- External reviewer approved with no blockers.

View File

@ -0,0 +1,148 @@
<!-- event: create author: tickets.sh at: 2026-06-01T00:16:16Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: hare at: 2026-06-01T00:16:59Z -->
## Plan
## Investigation notes
- Representative session: `~/.insomnia/sessions/019e8042-be06-72e2-bc80-05afdfde4515/`.
- First segment: `019e8042-be06-72e2-bc80-05b98007803a.jsonl`.
- Compact segment: `019e8043-4d63-7231-b0c7-3d356e86665a.jsonl` with `compacted_from.at_turn_index = 1`.
- The compact path appears to be mid-turn request threshold yield, not prune itself:
- `PodInterceptor::pre_llm_request()` checks `state.exceeds_request(current)`.
- `PreRequestAction::Yield` becomes `WorkerResult::Yielded`.
- `Pod::handle_worker_result()` runs `do_compact_and_resume()`.
- `prune.fire` observed in the same segment is useful context when reading the log, but prune is not the compact trigger and this ticket does not require prune behavior changes.
## Design constraint
Do not model system and history as exactly separable token domains unless the implementation can measure them as such. For compact thresholding, the stable property is whole request prompt occupancy.
---
<!-- event: plan author: hare at: 2026-06-01T00:41:18Z -->
## Plan
## Preflight classification
implementation-ready.
The ticket is a bounded bug fix in compact/request-threshold token accounting. The intended behavior is clear: compact thresholding should estimate whole request prompt occupancy and must not divide provider `input_total_tokens` by history-only bytes. Prune behavior is explicitly out of scope.
## Requirements sync
Observable completion:
- A fresh session / one prior usage record case with large fixed prompt overhead does not trigger request-threshold yield solely because history grew after the first measured request.
- Measured `input_total_tokens` remains authoritative for the exact measured request occupancy.
- Unmeasured request occupancy estimation uses a size measure that corresponds to the same whole request context being estimated, not history-only bytes.
Non-goals:
- Do not change prune behavior or prune savings policy for this ticket.
- Do not change compact thresholds or profile defaults as the fix.
- Do not alter session log schema unless the implementation finds it necessary and escalates first.
## Current code map
- `crates/llm-worker/src/token_counter.rs`: shared token estimate functions used for compact/request thresholding; current extrapolation uses history prefix bytes.
- `crates/pod/src/ipc/interceptor.rs`: `pre_llm_request` computes request-context estimate and yields when `request_threshold` is exceeded.
- `crates/pod/src/compact/token_counter.rs`: Pod-side wrappers and tests around token estimates; prune helpers exist here but are not in ticket scope.
- `crates/pod/src/compact/usage_tracker.rs`: captures usage records keyed by in-flight request history length.
- `crates/pod/src/compact/state.rs`: threshold semantics; should not need behavior changes.
- `crates/llm-worker/src/worker.rs`: request loop and prune projection before `pre_llm_request`; should not need lifecycle changes.
## Critical risks
- Fixing the estimate by simply raising thresholds or disabling request-threshold yield would hide the bug and is not acceptable.
- Splitting system/tool/history into separate exact token domains is not warranted unless the implementation can measure the same request shape consistently.
- Regression tests must exercise the one-measurement case, because that is where fixed prompt overhead previously dominated the inferred history rate.
- Reviewer should verify that prune behavior was not intentionally changed.
## Intent packet
Intent:
- Fix compact/request-threshold token occupancy estimation so whole prompt usage is not projected from history-only bytes.
Requirements:
- Treat exact usage records as authoritative for the measured request occupancy.
- Estimate unmeasured whole request occupancy using a request-size basis that corresponds to the whole request context, or a conservative fallback that does not allocate fixed prompt overhead to history bytes.
- Add regression coverage for first-turn/fresh-session overestimation.
Invariants:
- Compact remains triggered by threshold semantics, not by prune activity.
- Prune behavior is out of scope and should not be changed intentionally.
- Do not introduce a false exact system/history token split.
- Do not modify profile thresholds as the fix.
Escalate if:
- The clean fix requires session-log schema changes, provider request serialization changes, or durable migration.
- The implementation would change prune behavior or compact lifecycle semantics.
Validation:
- Focused Rust tests for `llm-worker` token counter and pod compact/interceptor behavior as applicable.
- `cargo test -p llm-worker token_counter` or narrower exact test target if available.
- `cargo test -p pod compact` or focused pod tests if touched.
- `cargo check --workspace` if focused tests pass and runtime is reasonable.
- `./tickets.sh doctor` in main workspace before finalization.
---
<!-- event: review author: hare at: 2026-06-01T00:59:20Z status: approve -->
## Review: approve
External reviewer recommendation: approve.
Summary:
- Exact UsageRecord matches remain authoritative.
- Extrapolation after the latest measurement no longer uses `latest_total_input_tokens / latest_history_bytes`.
- Interpolation between two measurements still uses measured deltas where fixed overhead cancels.
- One-measurement extrapolation uses a conservative byte fallback added to latest measured total.
- Regression coverage exists in `llm-worker` token counter tests and pod `pre_llm_request` behavior.
- No intentional prune policy/savings, threshold/default, session schema, or compact lifecycle changes were found.
Validation re-run by reviewer:
- `cargo test -p llm-worker token_counter` passed.
- `cargo test -p pod pre_llm_request_does_not_yield_from_single_measurement_history_rate_projection` passed.
- `git diff --check 3ea0058..HEAD` passed.
- `cargo fmt --check` passed.
Non-blocking follow-up:
- Some comments still describe extrapolation as a latest/final measurement rate even though the implementation is now latest measured incremental span or byte fallback. Reviewer classified this as documentation drift only, not a blocker.
---
<!-- event: close author: hare at: 2026-06-01T01:10:06Z status: closed -->
## Closed
Merged and completed.
Implementation:
- Merged branch `prompt-occupancy-token-estimator` into `develop` with `merge: prompt occupancy estimator`.
- `llm-worker` token counter extrapolation now keeps exact measured prompt occupancy authoritative and no longer extrapolates one-measurement growth via `total_input_tokens / history_bytes`.
- Extrapolation past the latest measurement uses a measured incremental span rate when available; otherwise it adds a conservative byte fallback for the unmeasured delta.
- Added pod interceptor regression coverage for the fresh-session / one-measurement overestimation case.
Validation after merge:
- `cargo test -p llm-worker token_counter` passed.
- `cargo test -p pod pre_llm_request_does_not_yield_from_single_measurement_history_rate_projection` passed.
- `./tickets.sh doctor` passed.
Review:
- External reviewer approved with no blockers.
---

View File

@ -1,7 +0,0 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:21Z -->
## Migrated
Migrated from TODO.md entry without a legacy ticket file. No legacy review file was present at migration time.
---

View File

@ -1,21 +0,0 @@
---
id: 20260527-194421-pod-orchestration-system-guidance
slug: pod-orchestration-system-guidance
title: Pod orchestration tool availability に応じた system guidance
status: open
kind: feature
priority: P2
labels: [pod, workflow, prompt]
created_at: 2026-05-27T19:44:21Z
updated_at: 2026-05-27T19:44:43Z
assignee: null
legacy_ticket: null
---
## Background
Created by tickets.sh.
## Acceptance criteria
- TBD

View File

@ -1,58 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-05-27T19:44:21Z -->
## Created
Created by tickets.sh create.
---
<!-- event: plan author: orchestrator at: 2026-05-27T19:44:43Z -->
## Plan
## Background
Pod notification / notice によって child Pod の完了や状態変化が見えても、現状の assistant はユーザーから明示的に「レビューして」「確認して」と言われるまで自発的に消化しないことがある。
AGENTS.md や workflow に multi-agent の運用は書かれているが、これは知識として読めるだけで、Pod 管理ツールが利用可能な turn における runtime 行動規範としては弱い。特に、自分が spawn した child Pod の完了通知は background signal として扱い、自然な区切りで `ReadPodOutput` / worktree status / diff / test を確認して次の action に進むべきである。
一方で、notification は non-blocking であり、進行中の user request を不必要に中断してまで消化すべきではない。system instruction には「自発的に follow-up するが、現在の user task を壊さない」というバランスを明示する必要がある。
## Requirements
- Pod management tools が有効な Worker にだけ、Pod orchestration 用の system guidance を注入する。
- 例: `SpawnPod` / `ReadPodOutput` / `SendToPod` / `StopPod` / `AttachOrRestorePod` などが利用可能な場合。
- Pod 管理 tool がない通常 Worker / child Pod には不要な guidance を出さない。
- guidance 本文は `resources/prompts` 配下に置く。
- prompt 文字列を Rust code に直書きしない。
- guidance には以下を含める。
- Pod notification / notice は、自分が処理すべき background signal として扱う。
- 自分が spawn した child Pod の完了通知を受けたら、自然な区切りで `ReadPodOutput` を確認する。
- 委譲 task が完了していれば、報告・worktree status・diff・test 結果を確認し、修正依頼 / merge / ticket 完了処理 / Pod 停止のいずれかに進む。
- user が明示的に follow-up を要求するまで routine follow-up を放置しない。
- ただし進行中の user request を不用意に中断しない。
- output / diff / test を確認せずに完了扱いしない。
- この guidance は scheduler / auto-maintainer ではない。
- workflow を勝手に開始しない。
- project decision / merge / cleanup は既存 workflow と user authorization に従う。
- notification / PodEvent を context に載せる場合は、既存の history 永続化原則を破らない。
- turn を跨げない情報を history に残さず system context にだけ差し込まない。
## Acceptance criteria
- Pod management tools が有効な Worker の system prompt に orchestration guidance が含まれる。
- Pod management tools が無効な Worker には含まれない。
- prompt 本文が `resources/prompts` にある。
- prompt assembly の test で conditional inclusion が確認されている。
- guidance が user request の中断を促さず、natural stopping point での follow-up を促す文言になっている。
- `cargo fmt --check` と関連 crate の test が通る。
## Out of scope
- 自動 scheduler / auto-maintain loop の実装。
- PodEvent / notification の protocol 変更。
- spawned Pod registry restore の修正。
- TUI notification UI の変更。
---