cobolx 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/agent/client.rs +107 -8
- package/src/agent/db_agent.rs +71 -23
- package/src/agent/explain_agent.rs +53 -22
- package/src/agent/fs_agent.rs +211 -83
- package/src/agent/skills.rs +336 -0
- package/src/agent/types.rs +7 -0
- package/src/agent.rs +1 -0
- package/src/cobol/indexer.rs +375 -5
- package/src/cobol/model.rs +78 -0
- package/src/cobol/scanner.rs +2 -0
- package/src/cobol/source_parser.rs +341 -2
- package/src/lib.rs +1 -0
- package/src/main.rs +1 -0
- package/src/memory/memories.rs +208 -0
- package/src/memory/runs.rs +161 -0
- package/src/memory/store.rs +120 -0
- package/src/memory.rs +8 -2
- package/src/path_safety.rs +280 -0
- package/src/ui/draw.rs +1 -0
- package/src/ui/tui.rs +239 -0
- package/tests/indexer_tests.rs +261 -0
- package/tests/project_files_tests.rs +23 -51
- package/src/memory/files.rs +0 -155
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
pub(crate) const MAX_AGENT_SKILL_CHARS: usize = 8 * 1024;
|
|
4
|
+
|
|
5
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
6
|
+
pub(crate) enum AgentKind {
|
|
7
|
+
Database,
|
|
8
|
+
FilesystemRetrieval,
|
|
9
|
+
Explain,
|
|
10
|
+
Verify,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl AgentKind {
|
|
14
|
+
pub(crate) fn skill_dir(self) -> &'static str {
|
|
15
|
+
match self {
|
|
16
|
+
AgentKind::Database => "database",
|
|
17
|
+
AgentKind::FilesystemRetrieval => "filesystem",
|
|
18
|
+
AgentKind::Explain => "explain",
|
|
19
|
+
AgentKind::Verify => "verify",
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn prompt_label(self) -> &'static str {
|
|
24
|
+
self.label()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
pub(crate) fn label(self) -> &'static str {
|
|
28
|
+
match self {
|
|
29
|
+
AgentKind::Database => "Database Sub-Agent",
|
|
30
|
+
AgentKind::FilesystemRetrieval => "Filesystem Retrieval Agent",
|
|
31
|
+
AgentKind::Explain => "Explain Agent",
|
|
32
|
+
AgentKind::Verify => "Verify Agent",
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pub(crate) const ALL: [AgentKind; 4] = [
|
|
37
|
+
AgentKind::Database,
|
|
38
|
+
AgentKind::FilesystemRetrieval,
|
|
39
|
+
AgentKind::Explain,
|
|
40
|
+
AgentKind::Verify,
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pub(crate) const AGENT_SKILL_DIRS: &[&str] = &["database", "filesystem", "explain", "verify"];
|
|
45
|
+
|
|
46
|
+
/// Result of actually exercising `load_agent_skills` for one agent kind, used to
|
|
47
|
+
/// verify (not just assume) that skill files on disk are really being picked up.
|
|
48
|
+
pub(crate) struct SkillCheck {
|
|
49
|
+
pub agent: AgentKind,
|
|
50
|
+
pub dir_exists: bool,
|
|
51
|
+
pub files: Vec<String>,
|
|
52
|
+
pub source_bytes: usize,
|
|
53
|
+
pub injected_bytes: usize,
|
|
54
|
+
pub truncated: bool,
|
|
55
|
+
pub error: Option<String>,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
impl SkillCheck {
|
|
59
|
+
pub(crate) fn is_active(&self) -> bool {
|
|
60
|
+
self.error.is_none() && self.injected_bytes > 0
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Re-runs the exact same code path each sub-agent uses (`load_agent_skills`) so the
|
|
65
|
+
/// result reflects reality, not just "a directory with .md files exists".
|
|
66
|
+
pub(crate) fn check_agent_skills(project_root: &Path, agent: AgentKind) -> SkillCheck {
|
|
67
|
+
let skill_root = project_root
|
|
68
|
+
.join(".cobolx")
|
|
69
|
+
.join("skills")
|
|
70
|
+
.join(agent.skill_dir());
|
|
71
|
+
let dir_exists = skill_root.is_dir();
|
|
72
|
+
|
|
73
|
+
if !dir_exists {
|
|
74
|
+
return SkillCheck {
|
|
75
|
+
agent,
|
|
76
|
+
dir_exists,
|
|
77
|
+
files: Vec::new(),
|
|
78
|
+
source_bytes: 0,
|
|
79
|
+
injected_bytes: 0,
|
|
80
|
+
truncated: false,
|
|
81
|
+
error: None,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let mut files = Vec::new();
|
|
86
|
+
if let Err(e) = collect_skill_files(&skill_root, &mut files) {
|
|
87
|
+
return SkillCheck {
|
|
88
|
+
agent,
|
|
89
|
+
dir_exists,
|
|
90
|
+
files: Vec::new(),
|
|
91
|
+
source_bytes: 0,
|
|
92
|
+
injected_bytes: 0,
|
|
93
|
+
truncated: false,
|
|
94
|
+
error: Some(e),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
files.sort();
|
|
98
|
+
|
|
99
|
+
let source_bytes: usize = files
|
|
100
|
+
.iter()
|
|
101
|
+
.map(|p| std::fs::metadata(p).map(|m| m.len() as usize).unwrap_or(0))
|
|
102
|
+
.sum();
|
|
103
|
+
let rel_files: Vec<String> = files
|
|
104
|
+
.iter()
|
|
105
|
+
.map(|p| {
|
|
106
|
+
p.strip_prefix(project_root)
|
|
107
|
+
.unwrap_or(p)
|
|
108
|
+
.to_string_lossy()
|
|
109
|
+
.replace('\\', "/")
|
|
110
|
+
})
|
|
111
|
+
.collect();
|
|
112
|
+
|
|
113
|
+
match load_agent_skills(project_root, agent) {
|
|
114
|
+
Ok(Some(loaded)) => SkillCheck {
|
|
115
|
+
agent,
|
|
116
|
+
dir_exists,
|
|
117
|
+
files: rel_files,
|
|
118
|
+
source_bytes,
|
|
119
|
+
injected_bytes: loaded.len(),
|
|
120
|
+
truncated: loaded.contains("[agent skills truncated]"),
|
|
121
|
+
error: None,
|
|
122
|
+
},
|
|
123
|
+
Ok(None) => SkillCheck {
|
|
124
|
+
agent,
|
|
125
|
+
dir_exists,
|
|
126
|
+
files: rel_files,
|
|
127
|
+
source_bytes,
|
|
128
|
+
injected_bytes: 0,
|
|
129
|
+
truncated: false,
|
|
130
|
+
error: None,
|
|
131
|
+
},
|
|
132
|
+
Err(e) => SkillCheck {
|
|
133
|
+
agent,
|
|
134
|
+
dir_exists,
|
|
135
|
+
files: rel_files,
|
|
136
|
+
source_bytes,
|
|
137
|
+
injected_bytes: 0,
|
|
138
|
+
truncated: false,
|
|
139
|
+
error: Some(e),
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pub(crate) fn check_all_agent_skills(project_root: &Path) -> Vec<SkillCheck> {
|
|
145
|
+
AgentKind::ALL
|
|
146
|
+
.iter()
|
|
147
|
+
.map(|&agent| check_agent_skills(project_root, agent))
|
|
148
|
+
.collect()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
pub(crate) fn append_agent_skills(
|
|
152
|
+
system_prompt: &mut String,
|
|
153
|
+
project_root: &Path,
|
|
154
|
+
agent: AgentKind,
|
|
155
|
+
) -> Result<(), String> {
|
|
156
|
+
if let Some(skills) = load_agent_skills(project_root, agent)? {
|
|
157
|
+
system_prompt.push_str("\n\n");
|
|
158
|
+
system_prompt.push_str(&skills);
|
|
159
|
+
}
|
|
160
|
+
Ok(())
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pub(crate) fn load_agent_skills(
|
|
164
|
+
project_root: &Path,
|
|
165
|
+
agent: AgentKind,
|
|
166
|
+
) -> Result<Option<String>, String> {
|
|
167
|
+
let skill_root = project_root
|
|
168
|
+
.join(".cobolx")
|
|
169
|
+
.join("skills")
|
|
170
|
+
.join(agent.skill_dir());
|
|
171
|
+
if !skill_root.exists() {
|
|
172
|
+
return Ok(None);
|
|
173
|
+
}
|
|
174
|
+
if !skill_root.is_dir() {
|
|
175
|
+
return Err(format!(
|
|
176
|
+
"Agent skill path is not a directory: {}",
|
|
177
|
+
skill_root.to_string_lossy()
|
|
178
|
+
));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let mut files = Vec::new();
|
|
182
|
+
collect_skill_files(&skill_root, &mut files)?;
|
|
183
|
+
files.sort();
|
|
184
|
+
if files.is_empty() {
|
|
185
|
+
return Ok(None);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let mut out = format!(
|
|
189
|
+
"## Agent Skills ({})\n\
|
|
190
|
+
These instructions are scoped to this sub-agent only. Do not infer that other agents saw them.\n\n",
|
|
191
|
+
agent.prompt_label()
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
for path in files {
|
|
195
|
+
let rel = path
|
|
196
|
+
.strip_prefix(project_root)
|
|
197
|
+
.unwrap_or(&path)
|
|
198
|
+
.to_string_lossy()
|
|
199
|
+
.replace('\\', "/");
|
|
200
|
+
let header = format!("### {}\n\n", rel);
|
|
201
|
+
if !push_bounded(&mut out, &header, MAX_AGENT_SKILL_CHARS) {
|
|
202
|
+
return Ok(Some(out));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let content = std::fs::read_to_string(&path)
|
|
206
|
+
.map_err(|e| format!("Failed to read skill {}: {e}", path.to_string_lossy()))?;
|
|
207
|
+
if !push_bounded(&mut out, content.trim(), MAX_AGENT_SKILL_CHARS) {
|
|
208
|
+
return Ok(Some(out));
|
|
209
|
+
}
|
|
210
|
+
if !push_bounded(&mut out, "\n\n", MAX_AGENT_SKILL_CHARS) {
|
|
211
|
+
return Ok(Some(out));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
Ok(Some(out))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
fn collect_skill_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> {
|
|
219
|
+
let entries = std::fs::read_dir(dir).map_err(|e| {
|
|
220
|
+
format!(
|
|
221
|
+
"Failed to read skill directory {}: {e}",
|
|
222
|
+
dir.to_string_lossy()
|
|
223
|
+
)
|
|
224
|
+
})?;
|
|
225
|
+
for entry in entries {
|
|
226
|
+
let entry = entry.map_err(|e| format!("Failed to read skill directory entry: {e}"))?;
|
|
227
|
+
let path = entry.path();
|
|
228
|
+
let file_type = entry
|
|
229
|
+
.file_type()
|
|
230
|
+
.map_err(|e| format!("Failed to read file type {}: {e}", path.to_string_lossy()))?;
|
|
231
|
+
|
|
232
|
+
if file_type.is_dir() {
|
|
233
|
+
if is_hidden_path(&path) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
collect_skill_files(&path, files)?;
|
|
237
|
+
} else if file_type.is_file() && is_markdown_file(&path) {
|
|
238
|
+
files.push(path);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
Ok(())
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
fn is_markdown_file(path: &Path) -> bool {
|
|
245
|
+
path.extension()
|
|
246
|
+
.and_then(|s| s.to_str())
|
|
247
|
+
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn is_hidden_path(path: &Path) -> bool {
|
|
251
|
+
path.file_name()
|
|
252
|
+
.and_then(|s| s.to_str())
|
|
253
|
+
.is_some_and(|name| name.starts_with('.'))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fn push_bounded(out: &mut String, text: &str, max_bytes: usize) -> bool {
|
|
257
|
+
if out.len() >= max_bytes {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
let remaining = max_bytes - out.len();
|
|
261
|
+
if text.len() <= remaining {
|
|
262
|
+
out.push_str(text);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const MARKER: &str = "\n\n[agent skills truncated]\n";
|
|
267
|
+
let budget = remaining.saturating_sub(MARKER.len());
|
|
268
|
+
let mut end = budget.min(text.len());
|
|
269
|
+
while end > 0 && !text.is_char_boundary(end) {
|
|
270
|
+
end -= 1;
|
|
271
|
+
}
|
|
272
|
+
out.push_str(&text[..end]);
|
|
273
|
+
if out.len() + MARKER.len() <= max_bytes {
|
|
274
|
+
out.push_str(MARKER);
|
|
275
|
+
}
|
|
276
|
+
false
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#[cfg(test)]
|
|
280
|
+
mod tests {
|
|
281
|
+
use super::*;
|
|
282
|
+
use tempfile::tempdir;
|
|
283
|
+
|
|
284
|
+
#[test]
|
|
285
|
+
fn loads_only_the_requested_agent_skill_directory() {
|
|
286
|
+
let dir = tempdir().unwrap();
|
|
287
|
+
let database_dir = dir.path().join(".cobolx/skills/database");
|
|
288
|
+
let explain_dir = dir.path().join(".cobolx/skills/explain");
|
|
289
|
+
std::fs::create_dir_all(&database_dir).unwrap();
|
|
290
|
+
std::fs::create_dir_all(&explain_dir).unwrap();
|
|
291
|
+
std::fs::write(database_dir.join("query.md"), "DB_ONLY").unwrap();
|
|
292
|
+
std::fs::write(explain_dir.join("report.md"), "EXPLAIN_ONLY").unwrap();
|
|
293
|
+
|
|
294
|
+
let loaded = load_agent_skills(dir.path(), AgentKind::Database)
|
|
295
|
+
.unwrap()
|
|
296
|
+
.unwrap();
|
|
297
|
+
|
|
298
|
+
assert!(loaded.contains("DB_ONLY"));
|
|
299
|
+
assert!(!loaded.contains("EXPLAIN_ONLY"));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#[test]
|
|
303
|
+
fn loads_markdown_skills_in_deterministic_order() {
|
|
304
|
+
let dir = tempdir().unwrap();
|
|
305
|
+
let skill_dir = dir.path().join(".cobolx/skills/filesystem/nested");
|
|
306
|
+
std::fs::create_dir_all(&skill_dir).unwrap();
|
|
307
|
+
std::fs::write(skill_dir.parent().unwrap().join("b.md"), "B").unwrap();
|
|
308
|
+
std::fs::write(skill_dir.parent().unwrap().join("a.md"), "A").unwrap();
|
|
309
|
+
std::fs::write(skill_dir.join("c.txt"), "ignored").unwrap();
|
|
310
|
+
|
|
311
|
+
let loaded = load_agent_skills(dir.path(), AgentKind::FilesystemRetrieval)
|
|
312
|
+
.unwrap()
|
|
313
|
+
.unwrap();
|
|
314
|
+
let a = loaded.find("a.md").unwrap();
|
|
315
|
+
let b = loaded.find("b.md").unwrap();
|
|
316
|
+
|
|
317
|
+
assert!(a < b);
|
|
318
|
+
assert!(!loaded.contains("ignored"));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#[test]
|
|
322
|
+
fn bounds_skill_injection_at_utf8_boundary() {
|
|
323
|
+
let dir = tempdir().unwrap();
|
|
324
|
+
let skill_dir = dir.path().join(".cobolx/skills/verify");
|
|
325
|
+
std::fs::create_dir_all(&skill_dir).unwrap();
|
|
326
|
+
std::fs::write(skill_dir.join("large.md"), "你".repeat(10_000)).unwrap();
|
|
327
|
+
|
|
328
|
+
let loaded = load_agent_skills(dir.path(), AgentKind::Verify)
|
|
329
|
+
.unwrap()
|
|
330
|
+
.unwrap();
|
|
331
|
+
|
|
332
|
+
assert!(loaded.len() <= MAX_AGENT_SKILL_CHARS);
|
|
333
|
+
assert!(loaded.is_char_boundary(loaded.len()));
|
|
334
|
+
assert!(loaded.contains("agent skills truncated"));
|
|
335
|
+
}
|
|
336
|
+
}
|
package/src/agent/types.rs
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
use serde::{Deserialize, Serialize};
|
|
2
|
+
use std::path::PathBuf;
|
|
3
|
+
use std::sync::{Arc, Mutex};
|
|
4
|
+
|
|
5
|
+
/// A buffered sandbox file write (absolute path, content).
|
|
6
|
+
pub type WriteBufferEntry = (PathBuf, String);
|
|
7
|
+
pub type WriteBuffer = Mutex<Vec<WriteBufferEntry>>;
|
|
8
|
+
pub type SharedWriteBuffer = Arc<WriteBuffer>;
|
|
2
9
|
|
|
3
10
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
4
11
|
pub struct ChatMessage {
|