Compare commits

...

10 Commits

12 changed files with 608 additions and 170 deletions

View File

@ -4,7 +4,6 @@
- AI maintainer 用 WorkItem / Thread 抽象 → [tickets/maintainer-work-items.md](tickets/maintainer-work-items.md) - AI maintainer 用 WorkItem / Thread 抽象 → [tickets/maintainer-work-items.md](tickets/maintainer-work-items.md)
- Prompt / Workflow 評価メトリクスと改善 Offer → [tickets/prompt-eval-metrics.md](tickets/prompt-eval-metrics.md) - Prompt / Workflow 評価メトリクスと改善 Offer → [tickets/prompt-eval-metrics.md](tickets/prompt-eval-metrics.md)
- Permission: allow-all 既定 policy への整理 → [tickets/permission-default-policy.md](tickets/permission-default-policy.md) - Permission: allow-all 既定 policy への整理 → [tickets/permission-default-policy.md](tickets/permission-default-policy.md)
- Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md)
- Pod: 空応答ターン (Submit 後 AI 応答ゼロで Pause/Cancel) を自動巻き戻し → [tickets/pod-empty-turn-rollback.md](tickets/pod-empty-turn-rollback.md) - Pod: 空応答ターン (Submit 後 AI 応答ゼロで Pause/Cancel) を自動巻き戻し → [tickets/pod-empty-turn-rollback.md](tickets/pod-empty-turn-rollback.md)
- Pod: 任意ターンからの Fork複数ターン巻き戻しを汎用化 → [tickets/pod-session-fork.md](tickets/pod-session-fork.md) - Pod: 任意ターンからの Fork複数ターン巻き戻しを汎用化 → [tickets/pod-session-fork.md](tickets/pod-session-fork.md)
- Pod: 子→親の TurnEnded/Errored callback を親由来ターンのみに絞る → [tickets/pod-parent-turn-callback.md](tickets/pod-parent-turn-callback.md) - Pod: 子→親の TurnEnded/Errored callback を親由来ターンのみに絞る → [tickets/pod-parent-turn-callback.md](tickets/pod-parent-turn-callback.md)
@ -16,12 +15,13 @@
- ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md) - ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
- E2E テストハーネス(`tests/e2e/`、opt-in → [tickets/e2e-harness.md](tickets/e2e-harness.md) - E2E テストハーネス(`tests/e2e/`、opt-in → [tickets/e2e-harness.md](tickets/e2e-harness.md)
- TUI 拡充 - TUI 拡充
- TUI から任意タイミングで Compact を発火する system command → [tickets/tui-system-command-compact.md](tickets/tui-system-command-compact.md)
- user manifest env override 時の spawn scope overlay 前提ズレ → [tickets/tui-user-manifest-env-overlay.md](tickets/tui-user-manifest-env-overlay.md)
- Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md) - Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
- ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md) - ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
- spawn 失敗時に Pod の stderr が TUI に表示されない → [tickets/tui-spawn-error-surface.md](tickets/tui-spawn-error-surface.md) - spawn 失敗時に Pod の stderr が TUI に表示されない → [tickets/tui-spawn-error-surface.md](tickets/tui-spawn-error-surface.md)
- 巻き戻されたターンの入力テキストを編集領域に復元 → [tickets/tui-empty-turn-restore.md](tickets/tui-empty-turn-restore.md) - 巻き戻されたターンの入力テキストを編集領域に復元 → [tickets/tui-empty-turn-restore.md](tickets/tui-empty-turn-restore.md)
- セッションコンテキスト長 / ウィンドウ占有率の常時表示 → [tickets/tui-context-usage-indicator.md](tickets/tui-context-usage-indicator.md) - セッションコンテキスト長 / ウィンドウ占有率の常時表示 → [tickets/tui-context-usage-indicator.md](tickets/tui-context-usage-indicator.md)
- Submit 時 FileRef でディレクトリを参照したときの挙動 → [tickets/file-ref-directory.md](tickets/file-ref-directory.md)
- Prune: 保護境界を turn 数から末尾 token budget に置き換え → [tickets/prune-token-budget.md](tickets/prune-token-budget.md) - Prune: 保護境界を turn 数から末尾 token budget に置き換え → [tickets/prune-token-budget.md](tickets/prune-token-budget.md)
- メモリ機構 - メモリ機構
- extract / consolidation 監査ログ → [tickets/memory-audit-log.md](tickets/memory-audit-log.md) - extract / consolidation 監査ログ → [tickets/memory-audit-log.md](tickets/memory-audit-log.md)

View File

@ -200,6 +200,8 @@ pub struct WorkerManifest {
#[serde(default)] #[serde(default)]
pub tool_output: ToolOutputLimits, pub tool_output: ToolOutputLimits,
/// Byte-size cap applied to submit-time FileRef uploads / attachments. /// Byte-size cap applied to submit-time FileRef uploads / attachments.
/// For file refs this caps the file body; for normal directory refs this
/// caps the rendered shallow listing body.
/// This is intentionally separate from tool-output truncation because /// This is intentionally separate from tool-output truncation because
/// user-requested file attachments can usually tolerate a larger budget. /// user-requested file attachments can usually tolerate a larger budget.
#[serde(default)] #[serde(default)]
@ -226,11 +228,13 @@ pub struct ToolOutputLimits {
/// Byte-size cap for submit-time FileRef uploads / attachments. /// Byte-size cap for submit-time FileRef uploads / attachments.
/// ///
/// This governs the `[File: <path>]` system-message attachment produced /// This governs the `[File: <path>]` system-message attachment produced
/// when a user explicitly submits a `@<path>` reference. It does not affect /// when a user explicitly submits a `@<path>` file reference, and the
/// tool result truncation; see [`ToolOutputLimits`] for that path. /// rendered body of a shallow `[Dir: <path>]` listing for a normal directory
/// reference. It does not affect tool result truncation; see
/// [`ToolOutputLimits`] for that path.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileUploadLimits { pub struct FileUploadLimits {
/// Cap applied to each resolved FileRef body. /// Cap applied to each resolved FileRef file body or directory-listing body.
#[serde(default = "default_file_upload_max_bytes")] #[serde(default = "default_file_upload_max_bytes")]
pub max_bytes: usize, pub max_bytes: usize,
} }

View File

@ -13,11 +13,16 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use llm_worker::Item; use llm_worker::Item;
use manifest::Scope;
use tools::scoped_fs::first_symlink;
use tools::{ScopedFs, ToolsError}; use tools::{ScopedFs, ToolsError};
use tracing::warn; use tracing::warn;
/// 補完候補1件の最大数。`list_file_completions` がこの値を超えたら打ち切り。 /// 補完候補1件の最大数。`list_file_completions` がこの値を超えたら打ち切り。
const COMPLETION_LIMIT: usize = 100; const COMPLETION_LIMIT: usize = 100;
/// submit-time directory FileRef の shallow listing で返す最大 entry 数。
/// TUI completion と同じ浅い一覧という意味論に揃えるため、同じ上限を使う。
const DIR_FILE_REF_ENTRY_LIMIT: usize = COMPLETION_LIMIT;
/// Compact worker が `mark_read_required` で nominate した「次セッション開始時に /// Compact worker が `mark_read_required` で nominate した「次セッション開始時に
/// 自動で再読すべきファイル」のエントリ。 /// 自動で再読すべきファイル」のエントリ。
@ -106,16 +111,16 @@ impl PodFsView {
out out
} }
/// `path` を ScopedFs 経由で読み、`[File: <path>]\n<body>` 形式の /// `path` を ScopedFs 経由で解決し、submit 時の `Segment::FileRef`
/// system message を返す。submit 時の `Segment::FileRef` リゾルバが /// attachment 用 system message を返す。
/// 使う経路。
/// ///
/// - `path` は relative なら pwd 相対、absolute なら absolute として解釈 /// - `path` は relative なら pwd 相対、absolute なら absolute として解釈
/// - `max_bytes` を超える本文は切り詰め、末尾に /// - 通常ディレクトリは浅い entry listing として `[Dir: <path>]\n<body>` に展開する
/// `[...truncated, <total> bytes total — use read_file for the rest]` /// - ディレクトリ listing は hidden / gitignore を特別扱いせず、scope 上 readable な
/// を付与する /// 直下 entry だけを最大 `DIR_FILE_REF_ENTRY_LIMIT` 件返す
/// - ファイル本文またはディレクトリ listing 本文が `max_bytes` を超える場合は切り詰める
/// - 非 UTF-8 (バイナリ) は `ResolveError::Binary` で拒否 /// - 非 UTF-8 (バイナリ) は `ResolveError::Binary` で拒否
/// - スコープ外 / NotFound 等は `ResolveError::Fs` で返す /// - スコープ外 / NotFound / symlink directory 等は `ResolveError::Fs` で返す
pub fn resolve_file_ref(&self, path: &str, max_bytes: usize) -> Result<Item, ResolveError> { pub fn resolve_file_ref(&self, path: &str, max_bytes: usize) -> Result<Item, ResolveError> {
let p = Path::new(path); let p = Path::new(path);
let abs = if p.is_absolute() { let abs = if p.is_absolute() {
@ -123,6 +128,21 @@ impl PodFsView {
} else { } else {
self.fs.pwd().join(p) self.fs.pwd().join(p)
}; };
// 通常ディレクトリだけを FileRef listing として扱う。symlink を含むパスは
// `ScopedFs::read_bytes` に委ね、既存の symlink 診断
// (`SymlinkTargetIsDirectory` / `SymlinkOutOfScope` 等) を保つ。
if first_symlink(&abs).is_none() {
let scope = self.fs.scope();
if !scope.is_readable(&abs) {
return Err(ResolveError::Fs(ToolsError::OutOfScope(abs)));
}
let meta = metadata_for_file_ref(&abs).map_err(ResolveError::Fs)?;
if meta.is_dir() {
return render_dir_file_ref(path, &abs, max_bytes, scope.as_ref());
}
}
let bytes = self.fs.read_bytes(&abs).map_err(ResolveError::Fs)?; let bytes = self.fs.read_bytes(&abs).map_err(ResolveError::Fs)?;
let total = bytes.len(); let total = bytes.len();
let (body_bytes, truncated) = if total > max_bytes { let (body_bytes, truncated) = if total > max_bytes {
@ -204,6 +224,116 @@ pub fn slice_lines(text: &str, offset: usize, limit: Option<usize>) -> String {
lines[start..end].join("\n") lines[start..end].join("\n")
} }
#[derive(Debug, Clone, PartialEq, Eq)]
struct DirListingEntry {
display: String,
kind_rank: u8,
}
fn metadata_for_file_ref(path: &Path) -> Result<std::fs::Metadata, ToolsError> {
std::fs::metadata(path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()),
_ => ToolsError::io(path, e),
})
}
fn render_dir_file_ref(
original_path: &str,
abs: &Path,
max_bytes: usize,
scope: &Scope,
) -> Result<Item, ResolveError> {
let read_dir = std::fs::read_dir(abs).map_err(|e| ResolveError::Fs(ToolsError::io(abs, e)))?;
let mut entries = Vec::new();
for entry in read_dir {
let entry = entry.map_err(|e| ResolveError::Fs(ToolsError::io(abs, e)))?;
let path = entry.path();
if !scope.is_readable(&path) {
continue;
}
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(e) => return Err(ResolveError::Fs(ToolsError::io(&path, e))),
};
let mut display = entry.file_name().to_string_lossy().into_owned();
let kind_rank = if file_type.is_dir() {
display.push('/');
0
} else if file_type.is_symlink() {
display.push('@');
1
} else {
2
};
entries.push(DirListingEntry { display, kind_rank });
}
entries.sort_by(|a, b| {
a.kind_rank
.cmp(&b.kind_rank)
.then_with(|| a.display.cmp(&b.display))
});
let total_entries = entries.len();
let entry_truncated = total_entries > DIR_FILE_REF_ENTRY_LIMIT;
let body = if total_entries == 0 {
"(empty directory)".to_string()
} else {
entries
.iter()
.take(DIR_FILE_REF_ENTRY_LIMIT)
.map(|e| e.display.as_str())
.collect::<Vec<_>>()
.join("\n")
};
let body_total_bytes = body.len();
let (body, byte_truncated) = truncate_utf8_bytes(&body, max_bytes);
let mut text = format!("[Dir: {original_path}]\n{body}");
if entry_truncated || byte_truncated {
text.push('\n');
text.push_str(&dir_listing_truncation_hint(
entry_truncated,
byte_truncated,
total_entries,
body_total_bytes,
));
}
Ok(Item::system_message(text))
}
fn truncate_utf8_bytes(s: &str, max_bytes: usize) -> (&str, bool) {
if s.len() <= max_bytes {
return (s, false);
}
let mut end = max_bytes;
while !s.is_char_boundary(end) {
end -= 1;
}
(&s[..end], true)
}
fn dir_listing_truncation_hint(
entry_truncated: bool,
byte_truncated: bool,
total_entries: usize,
body_total_bytes: usize,
) -> String {
match (entry_truncated, byte_truncated) {
(true, true) => format!(
"[...truncated, {total_entries} readable entries total; first {DIR_FILE_REF_ENTRY_LIMIT} entries were {body_total_bytes} bytes before byte cap — use Glob for more]"
),
(true, false) => {
format!("[...truncated, {total_entries} readable entries total — use Glob for more]")
}
(false, true) => {
format!("[...truncated, {body_total_bytes} bytes total — use Glob or Read for more]")
}
(false, false) => String::new(),
}
}
fn format_range(offset: Option<usize>, limit: Option<usize>) -> String { fn format_range(offset: Option<usize>, limit: Option<usize>) -> String {
match (offset, limit) { match (offset, limit) {
(None, None) => String::new(), (None, None) => String::new(),
@ -239,6 +369,7 @@ fn split_prefix(prefix: &str, pwd: &Path) -> (PathBuf, String, bool) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use llm_worker::ContentPart;
use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
use tempfile::TempDir; use tempfile::TempDir;
@ -256,6 +387,16 @@ mod tests {
std::fs::write(path, content).unwrap(); std::fs::write(path, content).unwrap();
} }
fn system_text(item: &Item) -> &str {
let Item::Message { content, .. } = item else {
panic!("expected message item");
};
let Some(ContentPart::Text { text }) = content.first() else {
panic!("expected text content");
};
text
}
#[test] #[test]
fn slice_lines_handles_offset_and_limit() { fn slice_lines_handles_offset_and_limit() {
let text = "a\nb\nc\nd"; let text = "a\nb\nc\nd";
@ -312,6 +453,115 @@ mod tests {
assert!(text.contains("2048 bytes total")); assert!(text.contains("2048 bytes total"));
} }
#[test]
fn resolve_file_ref_lists_directory_shallow_entries() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("docs/sub")).unwrap();
touch(&dir.path().join("docs/.hidden"), "hidden");
touch(&dir.path().join("docs/.gitignore"), "ignored.txt\n");
touch(
&dir.path().join("docs/ignored.txt"),
"not ignored for FileRef",
);
let view = PodFsView::new(fs_for(&dir));
let item = view.resolve_file_ref("docs", 4096).unwrap();
let text = system_text(&item);
assert!(text.starts_with("[Dir: docs]\n"));
assert!(text.contains("sub/"));
assert!(text.contains(".hidden"));
assert!(text.contains(".gitignore"));
assert!(text.contains("ignored.txt"));
let sub_pos = text.find("sub/").unwrap();
let hidden_pos = text.find(".hidden").unwrap();
assert!(
sub_pos < hidden_pos,
"directories should sort before files:\n{text}"
);
}
#[test]
fn resolve_file_ref_directory_listing_filters_unreadable_entries() {
let dir = TempDir::new().unwrap();
let docs = dir.path().join("docs");
let secret = docs.join("secret");
std::fs::create_dir_all(&secret).unwrap();
touch(&docs.join("visible.txt"), "ok");
touch(&secret.join("hidden.txt"), "nope");
let cfg = ScopeConfig {
allow: vec![ScopeRule {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
}],
deny: vec![ScopeRule {
target: secret.clone(),
permission: Permission::Read,
recursive: true,
}],
};
let scope = Scope::from_config(&cfg).unwrap();
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
let view = PodFsView::new(fs);
let item = view.resolve_file_ref("docs", 4096).unwrap();
let text = system_text(&item);
assert!(text.contains("visible.txt"));
assert!(!text.contains("secret"));
assert!(!text.contains("hidden.txt"));
}
#[test]
fn resolve_file_ref_directory_listing_uses_upload_byte_cap() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("docs")).unwrap();
touch(&dir.path().join("docs/very-long-file-name.txt"), "");
touch(&dir.path().join("docs/another-long-file-name.txt"), "");
let view = PodFsView::new(fs_for(&dir));
let item = view.resolve_file_ref("docs", 10).unwrap();
let text = system_text(&item);
assert!(text.starts_with("[Dir: docs]\n"));
assert!(text.contains("truncated"));
assert!(text.contains("bytes total"));
assert!(text.contains("use Glob or Read for more"));
}
#[test]
fn resolve_file_ref_directory_listing_uses_completion_entry_limit() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("docs")).unwrap();
for i in 0..(DIR_FILE_REF_ENTRY_LIMIT + 5) {
touch(&dir.path().join(format!("docs/file-{i:03}.txt")), "");
}
let view = PodFsView::new(fs_for(&dir));
let item = view.resolve_file_ref("docs", 4096).unwrap();
let text = system_text(&item);
assert!(text.contains("105 readable entries total"));
assert!(text.contains("file-099.txt"));
assert!(!text.contains("file-100.txt"));
assert!(text.contains("use Glob for more"));
}
#[cfg(unix)]
#[test]
fn resolve_file_ref_directory_listing_marks_readable_symlink_entries() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("docs")).unwrap();
touch(&dir.path().join("docs/target.txt"), "target");
symlink("target.txt", dir.path().join("docs/link.txt")).unwrap();
let view = PodFsView::new(fs_for(&dir));
let item = view.resolve_file_ref("docs", 4096).unwrap();
let text = system_text(&item);
assert!(text.contains("link.txt@"));
}
#[test] #[test]
fn resolve_file_ref_rejects_binary_with_binary_error() { fn resolve_file_ref_rejects_binary_with_binary_error() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();

View File

@ -1,21 +1,24 @@
use std::path::PathBuf; use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::ExitCode; use std::process::ExitCode;
use clap::Parser; use clap::Parser;
use manifest::paths; use manifest::{PodManifest, paths};
use pod::{Pod, PodController, PodFactory}; use pod::{Pod, PodController, PodFactory, PromptLoader};
use session_store::{FsStore, SessionId}; use session_store::{FsStore, SessionId};
#[derive(Parser)] const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST";
#[derive(Debug, Parser)]
#[command( #[command(
name = "pod", name = "pod",
about = "Spawn a Pod process from cascaded manifest layers" about = "Spawn a Pod process from manifest layers or a single manifest file"
)] )]
struct Cli { struct Cli {
/// User manifest TOML. Defaults to `<config_dir>/manifest.toml` /// Manifest TOML to use directly, without loading user, project, or
/// (see `manifest::paths`). /// overlay layers.
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH", conflicts_with_all = ["project", "overlay"])]
user_manifest: Option<PathBuf>, manifest: Option<PathBuf>,
/// Start the project-manifest walk from this directory. When /// Start the project-manifest walk from this directory. When
/// omitted, the factory walks up from the current working /// omitted, the factory walks up from the current working
@ -53,10 +56,56 @@ struct Cli {
session: Option<SessionId>, session: Option<SessionId>,
} }
async fn build_factory(cli: &Cli) -> Result<PodFactory, String> { fn resolve_manifest(cli: &Cli) -> Result<(PodManifest, PromptLoader), String> {
resolve_manifest_with_user_manifest_env(cli, std::env::var_os(USER_MANIFEST_ENV))
}
fn resolve_manifest_with_user_manifest_env(
cli: &Cli,
user_manifest_env: Option<OsString>,
) -> Result<(PodManifest, PromptLoader), String> {
let user_manifest = user_manifest_path_from_env(user_manifest_env);
if let Some(path) = &cli.manifest {
if user_manifest.is_some() {
return Err(format!(
"--manifest cannot be used when {USER_MANIFEST_ENV} is set"
));
}
return load_single_manifest(path);
}
let factory = build_factory_with_user_manifest_path(cli, user_manifest)?;
factory
.resolve()
.map_err(|e| format!("failed to resolve manifest cascade: {e}"))
}
fn user_manifest_path_from_env(value: Option<OsString>) -> Option<PathBuf> {
value.and_then(|value| {
if value.is_empty() {
None
} else {
Some(PathBuf::from(value))
}
})
}
fn load_single_manifest(path: &Path) -> Result<(PodManifest, PromptLoader), String> {
let toml = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
let manifest = PodManifest::from_toml(&toml)
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?;
Ok((manifest, PromptLoader::builtins_only()))
}
fn build_factory_with_user_manifest_path(
cli: &Cli,
user_manifest: Option<PathBuf>,
) -> Result<PodFactory, String> {
let mut factory = PodFactory::new(); let mut factory = PodFactory::new();
factory = match &cli.user_manifest { factory = match user_manifest {
Some(path) => factory Some(path) => factory
.with_user_manifest(path) .with_user_manifest(path)
.map_err(|e| format!("failed to load user manifest: {e}"))?, .map_err(|e| format!("failed to load user manifest: {e}"))?,
@ -87,18 +136,10 @@ async fn build_factory(cli: &Cli) -> Result<PodFactory, String> {
async fn main() -> ExitCode { async fn main() -> ExitCode {
let cli = Cli::parse(); let cli = Cli::parse();
let factory = match build_factory(&cli).await { let (manifest, loader) = match resolve_manifest(&cli) {
Ok(f) => f,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let (manifest, loader) = match factory.resolve() {
Ok(pair) => pair, Ok(pair) => pair,
Err(e) => { Err(e) => {
eprintln!("error: failed to resolve manifest cascade: {e}"); eprintln!("error: {e}");
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
}; };
@ -202,3 +243,127 @@ async fn main() -> ExitCode {
drop(handle); drop(handle);
ExitCode::SUCCESS ExitCode::SUCCESS
} }
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
fn manifest_toml(name: &str, scope: &Path) -> String {
format!(
r#"
[pod]
name = "{name}"
[model]
scheme = "anthropic"
model_id = "test-model"
[worker]
[[scope.allow]]
target = "{scope}"
permission = "write"
"#,
scope = scope.display()
)
}
#[test]
fn user_manifest_flag_is_not_accepted() {
let err = Cli::try_parse_from(["pod", "--user-manifest", "manifest.toml"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn manifest_conflicts_with_project_and_overlay() {
let project_err =
Cli::try_parse_from(["pod", "--manifest", "manifest.toml", "--project", "."])
.unwrap_err();
assert_eq!(project_err.kind(), clap::error::ErrorKind::ArgumentConflict);
let overlay_err = Cli::try_parse_from([
"pod",
"--manifest",
"manifest.toml",
"--overlay",
"pod.name = 'x'",
])
.unwrap_err();
assert_eq!(overlay_err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn manifest_conflicts_with_user_manifest_env_when_env_is_non_empty() {
let tmp = TempDir::new().unwrap();
let manifest = tmp.path().join("manifest.toml");
write(&manifest, &manifest_toml("single", tmp.path()));
let cli = Cli::try_parse_from(["pod", "--manifest", manifest.to_str().unwrap()]).unwrap();
let err = resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml")))
.unwrap_err();
assert!(err.contains("--manifest cannot be used"));
assert!(err.contains(USER_MANIFEST_ENV));
}
#[test]
fn manifest_allows_empty_user_manifest_env() {
let tmp = TempDir::new().unwrap();
let manifest = tmp.path().join("manifest.toml");
write(&manifest, &manifest_toml("single", tmp.path()));
let cli = Cli::try_parse_from(["pod", "--manifest", manifest.to_str().unwrap()]).unwrap();
let (manifest, loader) =
resolve_manifest_with_user_manifest_env(&cli, Some(OsString::new())).unwrap();
assert_eq!(manifest.pod.name, "single");
assert!(loader.user_dir().is_none());
assert!(loader.workspace_dir().is_none());
}
#[test]
fn user_manifest_env_overrides_auto_user_manifest_path() {
let tmp = TempDir::new().unwrap();
let user_manifest = tmp.path().join("custom-user.toml");
write(&user_manifest, &manifest_toml("from-env", tmp.path()));
let no_project_root = tmp.path().join("no-project");
std::fs::create_dir_all(&no_project_root).unwrap();
let cli =
Cli::try_parse_from(["pod", "--project", no_project_root.to_str().unwrap()]).unwrap();
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(
&cli,
Some(user_manifest.as_os_str().to_os_string()),
)
.unwrap();
assert_eq!(manifest.pod.name, "from-env");
}
#[test]
fn manifest_mode_loads_single_file_with_minimal_prompt_loader() {
let tmp = TempDir::new().unwrap();
let single_manifest = tmp.path().join("single.toml");
write(&single_manifest, &manifest_toml("single-file", tmp.path()));
std::fs::create_dir_all(tmp.path().join("prompts")).unwrap();
std::fs::create_dir_all(tmp.path().join(".insomnia").join("prompts")).unwrap();
let cli =
Cli::try_parse_from(["pod", "--manifest", single_manifest.to_str().unwrap()]).unwrap();
let (manifest, loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap();
assert_eq!(manifest.pod.name, "single-file");
assert!(loader.user_dir().is_none());
assert!(loader.workspace_dir().is_none());
assert!(loader.user_pack_file().is_none());
assert!(loader.workspace_pack_file().is_none());
}
}

View File

@ -962,10 +962,9 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// ///
/// `input` is a typed segment list (see [`protocol::Segment`]). The /// `input` is a typed segment list (see [`protocol::Segment`]). The
/// Pod flattens it into a single user-message string for the /// Pod flattens it into a single user-message string for the
/// underlying Worker, expanding paste content inline and surfacing /// underlying Worker, expanding paste content inline, resolving file refs
/// alerts for any segment kind the current Pod has no resolver for /// into adjacent attachments where possible, and surfacing alerts for
/// (file refs, knowledge refs, workflow invocations, unknown /// unresolved refs / unsupported segment kinds.
/// variants from a newer client).
/// ///
/// If the between-turns compaction threshold is exceeded mid-run, /// If the between-turns compaction threshold is exceeded mid-run,
/// the Worker is aborted, history is compacted, and execution resumes /// the Worker is aborted, history is compacted, and execution resumes
@ -1018,10 +1017,11 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
} }
/// Resolve every `Segment::FileRef` in `segments` to a `[File: <path>]` /// Resolve every `Segment::FileRef` in `segments` to a `[File: <path>]`
/// system message via `PodFsView`. Resolution failures (out-of-scope, /// or shallow `[Dir: <path>]` system message via `PodFsView`. Resolution
/// not-found, binary, I/O) surface as `AlertLevel::Warn` Alerts and /// failures (out-of-scope, not-found, binary, I/O, unsupported symlink
/// are skipped — the unresolved placeholder stays in the flattened /// directory) surface as `AlertLevel::Warn` Alerts and are skipped — the
/// user message so the LLM still sees the intent. /// unresolved placeholder stays in the flattened user message so the LLM
/// still sees the intent.
fn resolve_file_refs(&self, segments: &[Segment]) -> Vec<Item> { fn resolve_file_refs(&self, segments: &[Segment]) -> Vec<Item> {
let view = crate::fs_view::PodFsView::new(tools::ScopedFs::with_shared_scope( let view = crate::fs_view::PodFsView::new(tools::ScopedFs::with_shared_scope(
self.scope.clone(), self.scope.clone(),

View File

@ -124,9 +124,10 @@ pub enum Segment {
lines: u32, lines: u32,
content: String, content: String,
}, },
/// `@<path>` file reference. Pod resolves to scope-checked file /// `@<path>` file-system reference. Pod resolves readable files to
/// content when a resolver is registered (resolver implementation /// `[File: <path>]` attachments and readable normal directories to shallow
/// out of scope for this ticket). /// `[Dir: <path>]` listings; the flattened user text keeps the literal
/// `@<path>` placeholder either way.
FileRef { path: String }, FileRef { path: String },
/// `#<slug>` Knowledge reference (see `docs/plan/memory.md`). /// `#<slug>` Knowledge reference (see `docs/plan/memory.md`).
KnowledgeRef { slug: String }, KnowledgeRef { slug: String },
@ -153,9 +154,9 @@ impl Segment {
/// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`) /// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
/// flatten back to their literal sigil form (`@<path>`, `#<slug>`, /// flatten back to their literal sigil form (`@<path>`, `#<slug>`,
/// `/<slug>`) — matching what the user originally typed. Resolved /// `/<slug>`) — matching what the user originally typed. Resolved
/// content (e.g. file body for `FileRef`) is delivered as separate /// content (e.g. file body or shallow directory listing for `FileRef`) is
/// `Item::system_message`s adjacent to the user message; the /// delivered as separate `Item::system_message`s adjacent to the user
/// resolution itself is the caller's job. `Unknown` falls back to /// message; the resolution itself is the caller's job. `Unknown` falls back to
/// a bracketed placeholder since there is no sigil to render. /// a bracketed placeholder since there is no sigil to render.
pub fn flatten_to_text(segments: &[Segment]) -> String { pub fn flatten_to_text(segments: &[Segment]) -> String {
let mut out = String::new(); let mut out = String::new();

View File

@ -32,7 +32,9 @@ impl PasteRef {
} }
} }
/// `@<path>` chip — confirmed completion of a file reference. /// `@<path>` chip — confirmed completion of a file-system reference.
/// Directories remain valid chips because Pod resolves normal directory refs
/// to shallow `[Dir: <path>]` listings at submit time.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FileRefAtom { pub struct FileRefAtom {
pub path: String, pub path: String,

View File

@ -299,17 +299,36 @@ import-map 形式のプレフィックスで指定する:
## `pod` CLI ## `pod` CLI
`pod` は通常、builtin default → user manifest → project manifest → overlay の cascade で manifest を解決して起動する。
``` ```
pod [--user-manifest <path>] [--project <path>] [--overlay <toml>] pod [--project <path>] [--overlay <toml>] [-s/--store <path>] [--session <uuid>]
[-s/--store <path>]
``` ```
| フラグ | 説明 | | フラグ | 説明 |
|---|---| |---|---|
| `--user-manifest` | ユーザー manifest のパス。省略時は `manifest::paths::user_manifest_path()` で自動解決 | | `--project <path>` | プロジェクト manifest 探索の起点。省略時は cwd から上方向に `.insomnia/manifest.toml` を探索 |
| `--project` | プロジェクト manifest 探索の起点。省略時は cwd から上方向に `.insomnia/` を探索 | | `--overlay <toml>` | 最上層の overlay を inline TOML 文字列で渡す(例: `--overlay 'worker.instruction = "$user/foo"'` |
| `--overlay` | 最上層の overlay を inline TOML 文字列で渡す(例: `--overlay 'worker.instruction = "$user/foo"'` | | `-s, --store <path>` | セッション永続化ディレクトリ(デフォルト: `<data_dir>/sessions/`、`manifest::paths` で解決) |
| `-s, --store` | セッション永続化ディレクトリ(デフォルト: `<data_dir>/sessions/`、`manifest::paths` で解決) | | `--session <uuid>` | 既存 session id から Pod を復元し、同じ jsonl に後続 turn を追記する |
user manifest は CLI フラグではなく、以下の規則で解決する。
| 入力 | 挙動 |
|---|---|
| `INSOMNIA_USER_MANIFEST=<path>` | 指定 path を user manifest として読む。ファイル不在や parse error は起動エラー |
| `INSOMNIA_USER_MANIFEST=` | 空文字列は未指定扱い |
| env 未指定 | `manifest::paths::user_manifest_path()` で自動探索し、存在すれば読む |
単一ファイルだけで起動したい場合は cascade を使わず、`--manifest` を指定する。
```
pod --manifest <path> [-s/--store <path>] [--session <uuid>]
```
`--manifest` は指定 TOML 1 枚だけを `PodManifest::from_toml` で読み、user / project / overlay layer は一切読まない。したがって `--project`、`--overlay`、非空の `INSOMNIA_USER_MANIFEST` とは併用不可。
spawn 子 Pod 用の内部フラグとして `--adopt``--callback <path>` がある。これらは `SpawnPod` が scope allocation と親 callback socket を引き継がせるために使うもので、通常の手動起動では使わない。
Pod の作業ディレクトリは `pod` 起動時の cwd が直接使われる。別ディレクトリで Pod の作業ディレクトリは `pod` 起動時の cwd が直接使われる。別ディレクトリで
動かしたい場合は `cd <path> && pod ...` のように外側で `cd` してから起動する。 動かしたい場合は `cd <path> && pod ...` のように外側で `cd` してから起動する。

View File

@ -1,64 +0,0 @@
# Submit 時 FileRef でディレクトリを参照したときの挙動
## 背景
submit 時に `Segment::FileRef { path }``crates/pod/src/fs_view.rs`
`PodFsView::resolve_file_ref` でファイル本文として読まれ、`[File: <path>]` system
message に展開される。内部では `ScopedFs::read_bytes` を経由するため、`path` が
ディレクトリだった場合は `ToolsError::IsDirectory` で失敗し、Pod 側は
`AlertLevel::Warn` を出してそのセグメントを丸ごと捨てる
`crates/pod/src/pod.rs` の `resolve_file_refs`)。
ユーザーから見ると `@some-dir` を添付しても LLM 側にはプレースホルダが残る
だけで、ディレクトリ参照という意図が反映されない。TUI completion は
ディレクトリも候補として出すので、実際の挙動とのギャップが大きい。
`tickets/file-ref-symlink-diagnostics.md` は symlink が canonicalize 後に
ディレクトリ・scope 外だった場合の診断にフォーカスしており、通常ディレクトリの
FileRef 解決をどう扱うかは扱っていない。
## ゴール
submit 時に `Segment::FileRef` がディレクトリを指している場合の挙動を
仕様として確定し、ユーザーが添付の意図どおりの結果を LLM に届けられる
ようにする。
## 要件
ディレクトリに対する FileRef の意味論を決め、実装する。少なくとも以下の
論点をチケット内で結論づけ、実装・テスト・ドキュメントを揃える:
- 採用する挙動: 浅い entry listing を `[Dir: <path>]` 等の label で返すか、
明示的に reject して `read_file` / `glob` の利用を促すか、それ以外か
- listing を返す場合の上限: entry 件数や本文 byte 数を、ファイル本文用の
upload/attachment 上限と同じにするか別途持つか
- 隠しファイル・gitignore・scope 外 entry の扱い
- symlink entry の扱いは `tickets/file-ref-symlink-diagnostics.md` と矛盾
しないこと(重複する判定は共通化を検討する)
- TUI completion がディレクトリ候補を出す挙動と整合すること(候補に出る
以上、submit 時に黙って捨てるのは UX として不整合)
## 完了条件
- 通常ディレクトリの FileRef が、`IsDirectory` Warn で黙って捨てられる
現状の挙動を残していない
- 採用した挙動が `PodFsView` のテストで覆われている
- `Segment::FileRef` のドキュメント / コメントが新仕様に揃っている
- TUI completion とのギャップが解消されている(ディレクトリを候補から
外すのか、submit でも扱うのか、いずれかに寄せる)
## 範囲外
- 再帰的なディレクトリ走査、glob 展開、`tree` 風の深い表現
- ディレクトリ内ファイルの自動 read 集約auto-read / fs view 側の責務)
- symlink 経由のディレクトリ判定の刷新
`tickets/file-ref-symlink-diagnostics.md` 側で扱う)
- upload / attachment 上限の manifest 化(`tickets/manifest-output-upload-limits.md`
## 参照
- `crates/pod/src/fs_view.rs` `resolve_file_ref` / `list_file_completions`
- `crates/pod/src/pod.rs` `resolve_file_refs`
- `crates/tools/src/scoped_fs.rs` `read_bytes`
- `tickets/file-ref-symlink-diagnostics.md`
- `tickets/manifest-output-upload-limits.md`

View File

@ -1,52 +0,0 @@
# Pod CLI: マニフェスト関連フラグの整理
## 背景
現状の `pod` CLI は `--user-manifest` / `--project` / `--overlay` の 3 つでカスケードを制御している(`crates/pod/src/main.rs`)。実運用で 2 つ足りない / 1 つ過剰:
- **`--user-manifest` フラグが過剰**: 個人マシンの user 設定は本来「一度設定して忘れる」性質のもので、毎回 CLI で渡し直す動機が薄い。XDG / `~/.config/insomnia/manifest.toml` の自動探索 + 環境変数による上書きがあれば足りる
- **環境変数連携が無い**: 「user 設定を別の場所に置きたい人」が手段を持たない
- **単一ファイル起動 mode が無い**: 「カスケードを一切回さず、このマニフェスト 1 枚だけで起動したい」という用途再現性確保、CI、検証目的等がカバーされていない。`--overlay` は cascade の最上位レイヤとして追加されるだけで、user / project 層は依然として読み込まれる
`--project` は workspace の起点を上書きする一般的なフラグなのでmanifest 探索だけでなく将来の prompts ディレクトリ等にも効く)、そのまま残す。`--overlay` も意味的に「cascade の最上位レイヤを差し込む」で正しいのでそのまま。
## 要件
### `--user-manifest` の削除と env への置換
`--user-manifest <PATH>` フラグは削除し、代わりに `INSOMNIA_USER_MANIFEST` 環境変数で user manifest のパスを上書きできるようにする。
- env 未指定: 既存の XDG 自動探索(`manifest::user_manifest_path()`
- env 指定: そのパスを user manifest として読む(ファイル不在はエラー、`with_user_manifest` 相当)
- env が空文字列なら未指定扱い
`pod/src/main.rs` から `user_manifest` フィールドを除き、`build_factory` 内の分岐も env 読み取りに置き換える。
### `--manifest <PATH>` の新設
カスケードを一切回さず、指定ファイル 1 枚だけを manifest として読み込んで Pod を起動する mode。
- `--project` / `--overlay` / `INSOMNIA_USER_MANIFEST` のいずれとも **併用不可**clap の `conflicts_with` + 環境変数の事前チェック)
- 内部的には `PodFactory` の cascade 経路を通さず、`PodManifest::from_toml`(既存)の直接ルートで構築する
- prompt loader 等 cascade 経由で組まれるものは、単一ファイル mode 用に最小構成で組み直す
### 既存挙動の保持
- `--project` / `--overlay`: 変更なし
- `--adopt` / `--callback`: 変更なしspawn 子 Pod 用、本フラグ群とは独立)
- 引数なしで `pod` を叩くと従来通り XDG → walk-up → 起動
## 範囲外
- `--no-user-manifest` / `--no-project-manifest` のような per-layer 無効化フラグ。`--manifest` で「全層無効化して 1 枚だけ」のケースはカバー出来るので、「user 層だけ無視して project 層は活かす」のような細粒度の需要が出てから別途
- `INSOMNIA_PROJECT_ROOT` 等、`--project` 側の env 連携(`--project` 自体の責務見直しと一緒に検討すべき領域なので、本チケットでは触らない)
- TUI spawn UI`tickets/tui-pod-spawn-ui.md`の挙動変更。spawn UI が組む overlay 経路は本チケット完了後も互換のまま動く
## 完了条件
- `--user-manifest` フラグが pod CLI から消えている
- `INSOMNIA_USER_MANIFEST=<path> pod` で user manifest のパスを上書きして起動できる
- `pod --manifest <path>` が単一ファイル mode で起動し、user / project / overlay 層は読まれない
- `--manifest``--project` / `--overlay` と併用しようとすると clap が拒否する
- `INSOMNIA_USER_MANIFEST` がセットされた状態で `--manifest` を使おうとするとエラーになる
- 既存の `pod --overlay <toml>` 起動(`start_pod.local.fish` 経由)が引き続き動く

View File

@ -0,0 +1,66 @@
# TUI から任意タイミングで Compact を実行する system command
## 背景
Compact は現在、manifest の compaction 設定と token 閾値に基づいて Pod 側で自動実行される。TUI は `CompactStart` / `CompactDone` / `CompactFailed` のイベントを受けて履歴上に表示できるが、ユーザーが TUI から任意のタイミングで Compact を明示実行する導線はない。
一方、TUI の入力欄にはすでに以下の sigil がある。
- `@`: FileRef / clipboard などの添付
- `#`: KnowledgeRef
- `/`: WorkflowRef
そのため、Compact のような Pod / TUI の制御操作を `/compact``#compact` として扱うと、既存の参照・補完体系と衝突する。通常の user message として LLM に送る入力とも明確に区別する必要がある。
## ゴール
TUI から、会話本文を送らずに任意タイミングで Compact を発火できるようにする。あわせて、Compact だけの特例ではなく、将来の TUI / Pod 制御操作にも使える system command の入口を用意する。
## 要件
### System command の入力体系
TUI に、通常の user message / `@` / `#` / `/` とは衝突しない system command の入力体系を追加する。
- `/``#` は使わない
- `@` FileRef とも衝突しない
- 入力された system command は LLM への user message として送られない
- command の発火は UI 上で見分けられる
- 未知 command や引数不正は、Pod に送らず TUI 側でユーザーに診断を出す
記法にするか、専用モードにするか、keybinding から command palette 的に起動するかは本チケット内で確定してよい。ただし既存の submit / completion / paste / chip 化の入力体験を壊さないこと。
### Manual Compact
System command から Compact を明示実行できるようにする。
- Idle 中に実行できる
- Run / Pause / spawn dialog / session picker など、実行できない状態では明確に拒否または無効化する
- 実行時に通常の user message は追加しない
- 既存の Compact lifecycle 表示(`CompactStart` / `CompactDone` / `CompactFailed`)と整合する
- Compact 完了後の session rotation / history restore / status 表示が、auto compact と同じ前提で動く
- compaction 設定が無い、または compactor model が解決できない場合の診断を TUI に出す
### Protocol / Pod 側
必要であれば、client → Pod の typed control method を追加する。
- `Method::Run` に特殊文字列を流す形にはしない
- Compact 実行は Pod 側の既存 compact 経路を使い、auto compact と履歴・store・broadcast のセマンティクスを分岐させない
- concurrent run 中の compact 要求、重複 compact 要求、shutdown 中の要求などの状態遷移を明確に扱う
## 完了条件
- TUI から任意タイミングで Compact を明示実行できる
- Compact 発火に使う system command の記法またはモードが、`@` / `#` / `/` と衝突していない
- system command が LLM への user message として history に混入しない
- 実行不可状態や失敗時の診断が TUI に表示される
- auto compact と同じ lifecycle event / history rotation 前提で表示が更新される
- protocol / Pod / TUI の必要なテストが追加されている
## 範囲外
- Compact の要約品質や prompt の変更
- compaction 閾値・retained token など manifest 設定の再設計
- slash command と WorkflowRef の意味論変更
- system command の豊富なコマンド群追加(本チケットでは Compact を最初の利用者として入口を作る)

View File

@ -0,0 +1,47 @@
# TUI spawn の user manifest env override と scope overlay の前提ズレ
## 背景
`pod` CLI は `tickets/pod-cli-manifest-flags.md``--user-manifest` を廃止し、`INSOMNIA_USER_MANIFEST` によって user manifest のパスを上書きできるようになった。
一方、TUI の spawn dialog は Pod 起動前に user / project manifest を先読みし、既存 cascade に `scope.allow` があるかどうかを見て、spawn overlay に cwd scope を補完するかを判断している。現状この先読みは `manifest::user_manifest_path()` に依存しており、`INSOMNIA_USER_MANIFEST` による user manifest override と一致しない可能性がある。
Pod の最終起動自体は `pod --overlay <toml>` 経由で行われるため、Pod 側では `INSOMNIA_USER_MANIFEST` が有効になる。しかし TUI が作る overlay は、別の user manifest を前提に組まれる可能性がある。
## 問題
`INSOMNIA_USER_MANIFEST` が設定されている場合、TUI spawn dialog が作成する scope overlay が実際に Pod CLI が読む manifest cascade とズレる可能性がある。
例:
- override された user manifest には `scope.allow` があるが、通常の XDG user manifest には無い
- TUI は「cascade に scope が無い」と判断して cwd scope を overlay に追加する
- 実際の Pod は override user manifest の scope と TUI overlay の cwd scope を両方読む
- 通常の XDG user manifest には `scope.allow` があるが、override された user manifest には無い
- TUI は「cascade に scope がある」と判断して cwd scope を overlay に追加しない
- 実際の Pod は override user manifest を読むため、期待より scope が狭い、または空になる可能性がある
この問題は spawn 段階で manifest が必須という意味ではなく、TUI が spawn overlay を補完するために行う cascade の事前見積もりが Pod CLI の manifest 解決規則と一致していない、という問題である。
## 要件
本チケットでは問題の記録を目的とする。対応方針はまだ決めない。
対応時には少なくとも以下を確認する:
- TUI spawn dialog が参照する user manifest 解決規則と、Pod CLI の `INSOMNIA_USER_MANIFEST` 解決規則の関係
- TUI が cwd scope を overlay に補完する条件が、実際の Pod 起動時 cascade と一致しているか
- `INSOMNIA_USER_MANIFEST` が空文字列の場合の扱い
- TUI 表示上の scope origin 表示が、実際に Pod が読む manifest と矛盾しないか
## 完了条件
- `INSOMNIA_USER_MANIFEST` 設定時に、TUI spawn dialog の scope overlay 補完が Pod CLI の実際の manifest cascade と矛盾しない
- TUI spawn dialog の表示・判断に使う user manifest 解決規則がコード上明確になっている
- 必要なテストが追加されている
## 範囲外
- TUI CLI フラグ全体のドキュメント整備
- Pod CLI の manifest flag 仕様変更
- `--project` や project manifest の env override 新設