Compare commits
10 Commits
8e50a9583a
...
69d67ab050
| Author | SHA1 | Date | |
|---|---|---|---|
| 69d67ab050 | |||
| 877e094a53 | |||
| 5a16cc6daf | |||
| b0c91049b1 | |||
| 418451ebf8 | |||
| 3d0dce2a2e | |||
| 4bde31e952 | |||
| 533610f053 | |||
| 7363b105f6 | |||
| 0ebe173009 |
4
TODO.md
4
TODO.md
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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` してから起動する。
|
||||||
|
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -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` 経由)が引き続き動く
|
|
||||||
66
tickets/tui-system-command-compact.md
Normal file
66
tickets/tui-system-command-compact.md
Normal 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 を最初の利用者として入口を作る)
|
||||||
47
tickets/tui-user-manifest-env-overlay.md
Normal file
47
tickets/tui-user-manifest-env-overlay.md
Normal 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 新設
|
||||||
Loading…
Reference in New Issue
Block a user