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
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
use crate::cobol::lexer::{clean_name, logical_lines, tokenize};
|
|
2
|
-
use crate::cobol::model::{
|
|
2
|
+
use crate::cobol::model::{
|
|
3
|
+
CallKind, CodeBlockKind, ExternalOpKind, ParsedCall, ParsedCodeBlock, ParsedCopy,
|
|
4
|
+
ParsedExternalOp, ParsedFile, ParsedIdentifier, ParsedLiteral, ParsedProgram, Token,
|
|
5
|
+
};
|
|
3
6
|
use std::path::Path;
|
|
4
7
|
|
|
5
8
|
pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
|
|
@@ -7,7 +10,14 @@ pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
|
|
|
7
10
|
let mut programs = Vec::new();
|
|
8
11
|
let mut copies = Vec::new();
|
|
9
12
|
let mut calls = Vec::new();
|
|
13
|
+
let mut code_blocks = Vec::new();
|
|
14
|
+
let mut external_ops = Vec::new();
|
|
15
|
+
let mut identifiers = Vec::new();
|
|
16
|
+
let mut literals = Vec::new();
|
|
10
17
|
let mut current_program = None::<String>;
|
|
18
|
+
let mut in_procedure_division = false;
|
|
19
|
+
let mut current_section = None::<String>;
|
|
20
|
+
let mut current_block_idx = None::<usize>;
|
|
11
21
|
|
|
12
22
|
for line in logical_lines(&content) {
|
|
13
23
|
let tokens = tokenize(&line.text);
|
|
@@ -15,6 +25,54 @@ pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
|
|
|
15
25
|
continue;
|
|
16
26
|
}
|
|
17
27
|
|
|
28
|
+
if has_two_tokens(&tokens, "PROCEDURE", "DIVISION") {
|
|
29
|
+
in_procedure_division = true;
|
|
30
|
+
current_section = None;
|
|
31
|
+
current_block_idx = None;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if in_procedure_division {
|
|
36
|
+
if let Some(section_name) = parse_section_name(&tokens) {
|
|
37
|
+
code_blocks.push(ParsedCodeBlock {
|
|
38
|
+
caller_name: current_program.clone(),
|
|
39
|
+
name: section_name.clone(),
|
|
40
|
+
kind: CodeBlockKind::Section,
|
|
41
|
+
parent_section: None,
|
|
42
|
+
start_offset: line.start_offset + tokens[0].start,
|
|
43
|
+
byte_len: line.byte_len,
|
|
44
|
+
statement_count: 0,
|
|
45
|
+
});
|
|
46
|
+
current_section = Some(section_name);
|
|
47
|
+
current_block_idx = Some(code_blocks.len() - 1);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if let Some(paragraph_name) = parse_paragraph_name(&line.text, &tokens) {
|
|
52
|
+
identifiers.push(ParsedIdentifier {
|
|
53
|
+
caller_name: current_program.clone(),
|
|
54
|
+
kind: "paragraph_name".to_string(),
|
|
55
|
+
value: paragraph_name.clone(),
|
|
56
|
+
start_offset: line.start_offset + tokens[0].start,
|
|
57
|
+
});
|
|
58
|
+
code_blocks.push(ParsedCodeBlock {
|
|
59
|
+
caller_name: current_program.clone(),
|
|
60
|
+
name: paragraph_name,
|
|
61
|
+
kind: CodeBlockKind::Paragraph,
|
|
62
|
+
parent_section: current_section.clone(),
|
|
63
|
+
start_offset: line.start_offset + tokens[0].start,
|
|
64
|
+
byte_len: line.byte_len,
|
|
65
|
+
statement_count: 0,
|
|
66
|
+
});
|
|
67
|
+
current_block_idx = Some(code_blocks.len() - 1);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if let Some(block_idx) = current_block_idx {
|
|
72
|
+
code_blocks[block_idx].statement_count += 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
18
76
|
for idx in 0..tokens.len() {
|
|
19
77
|
match tokens[idx].text.as_str() {
|
|
20
78
|
"PROGRAM-ID" => {
|
|
@@ -37,6 +95,7 @@ pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
|
|
|
37
95
|
.find(|t| t.text == "REPLACING")
|
|
38
96
|
.map(|t| line.text[t.start..].trim().to_string());
|
|
39
97
|
copies.push(ParsedCopy {
|
|
98
|
+
caller_name: current_program.clone(),
|
|
40
99
|
name,
|
|
41
100
|
start_offset: line.start_offset + tokens[idx].start,
|
|
42
101
|
byte_len: line.byte_len,
|
|
@@ -56,18 +115,89 @@ pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
|
|
|
56
115
|
};
|
|
57
116
|
calls.push(ParsedCall {
|
|
58
117
|
caller_name: current_program.clone(),
|
|
59
|
-
target: name,
|
|
118
|
+
target: name.clone(),
|
|
60
119
|
kind,
|
|
61
120
|
start_offset: line.start_offset + tokens[idx].start,
|
|
62
121
|
byte_len: line.byte_len,
|
|
63
122
|
using_count: count_using_args(&tokens[idx + 2..]),
|
|
64
123
|
});
|
|
124
|
+
external_ops.push(ParsedExternalOp {
|
|
125
|
+
caller_name: current_program.clone(),
|
|
126
|
+
kind: if target.quoted {
|
|
127
|
+
ExternalOpKind::CallLiteral
|
|
128
|
+
} else {
|
|
129
|
+
ExternalOpKind::CallIdentifier
|
|
130
|
+
},
|
|
131
|
+
verb: "CALL".to_string(),
|
|
132
|
+
target: Some(name.clone()),
|
|
133
|
+
start_offset: line.start_offset + tokens[idx].start,
|
|
134
|
+
byte_len: line.byte_len,
|
|
135
|
+
});
|
|
136
|
+
if target.quoted {
|
|
137
|
+
literals.push(ParsedLiteral {
|
|
138
|
+
caller_name: current_program.clone(),
|
|
139
|
+
kind: "call_target".to_string(),
|
|
140
|
+
value: name,
|
|
141
|
+
start_offset: line.start_offset + target.start,
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
identifiers.push(ParsedIdentifier {
|
|
145
|
+
caller_name: current_program.clone(),
|
|
146
|
+
kind: "call_target_identifier".to_string(),
|
|
147
|
+
value: name,
|
|
148
|
+
start_offset: line.start_offset + target.start,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
65
151
|
}
|
|
66
152
|
}
|
|
67
153
|
}
|
|
68
154
|
_ => {}
|
|
69
155
|
}
|
|
70
156
|
}
|
|
157
|
+
|
|
158
|
+
if !in_procedure_division {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if let Some((op, mut ids, mut lits)) = parse_exec_sql(
|
|
163
|
+
&tokens,
|
|
164
|
+
line.start_offset,
|
|
165
|
+
line.byte_len,
|
|
166
|
+
current_program.clone(),
|
|
167
|
+
) {
|
|
168
|
+
external_ops.push(op);
|
|
169
|
+
identifiers.append(&mut ids);
|
|
170
|
+
literals.append(&mut lits);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if let Some((op, mut lits)) = parse_exec_cics(
|
|
175
|
+
&tokens,
|
|
176
|
+
line.start_offset,
|
|
177
|
+
line.byte_len,
|
|
178
|
+
current_program.clone(),
|
|
179
|
+
) {
|
|
180
|
+
external_ops.push(op);
|
|
181
|
+
literals.append(&mut lits);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if let Some((verb, target)) = parse_file_io(&tokens) {
|
|
186
|
+
external_ops.push(ParsedExternalOp {
|
|
187
|
+
caller_name: current_program.clone(),
|
|
188
|
+
kind: ExternalOpKind::FileIo,
|
|
189
|
+
verb: verb.to_string(),
|
|
190
|
+
target: Some(target.clone()),
|
|
191
|
+
start_offset: line.start_offset + tokens[0].start,
|
|
192
|
+
byte_len: line.byte_len,
|
|
193
|
+
});
|
|
194
|
+
identifiers.push(ParsedIdentifier {
|
|
195
|
+
caller_name: current_program.clone(),
|
|
196
|
+
kind: "file_name".to_string(),
|
|
197
|
+
value: target,
|
|
198
|
+
start_offset: line.start_offset + tokens[1].start,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
71
201
|
}
|
|
72
202
|
|
|
73
203
|
Ok(ParsedFile {
|
|
@@ -75,6 +205,10 @@ pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
|
|
|
75
205
|
programs,
|
|
76
206
|
copies,
|
|
77
207
|
calls,
|
|
208
|
+
code_blocks,
|
|
209
|
+
external_ops,
|
|
210
|
+
identifiers,
|
|
211
|
+
literals,
|
|
78
212
|
})
|
|
79
213
|
}
|
|
80
214
|
|
|
@@ -89,3 +223,208 @@ fn count_using_args(tokens: &[Token]) -> usize {
|
|
|
89
223
|
.filter(|t| !matches!(t.text.as_str(), "BY" | "REFERENCE" | "CONTENT" | "VALUE"))
|
|
90
224
|
.count()
|
|
91
225
|
}
|
|
226
|
+
|
|
227
|
+
fn has_two_tokens(tokens: &[Token], first: &str, second: &str) -> bool {
|
|
228
|
+
tokens.len() >= 2 && tokens[0].text == first && tokens[1].text == second
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
fn parse_section_name(tokens: &[Token]) -> Option<String> {
|
|
232
|
+
(tokens.len() >= 2 && tokens[1].text == "SECTION").then(|| clean_name(&tokens[0].text))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
fn parse_paragraph_name(line: &str, tokens: &[Token]) -> Option<String> {
|
|
236
|
+
if tokens.len() != 1 || !line.trim_end().ends_with('.') {
|
|
237
|
+
return None;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let name = clean_name(&tokens[0].text);
|
|
241
|
+
(!name.is_empty() && !is_reserved_label(&name)).then_some(name)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
fn is_reserved_label(name: &str) -> bool {
|
|
245
|
+
matches!(
|
|
246
|
+
name,
|
|
247
|
+
"ACCEPT"
|
|
248
|
+
| "ADD"
|
|
249
|
+
| "CALL"
|
|
250
|
+
| "CANCEL"
|
|
251
|
+
| "CLOSE"
|
|
252
|
+
| "COMPUTE"
|
|
253
|
+
| "CONTINUE"
|
|
254
|
+
| "DELETE"
|
|
255
|
+
| "DISPLAY"
|
|
256
|
+
| "DIVIDE"
|
|
257
|
+
| "ELSE"
|
|
258
|
+
| "END-CALL"
|
|
259
|
+
| "END-EVALUATE"
|
|
260
|
+
| "END-IF"
|
|
261
|
+
| "END-PERFORM"
|
|
262
|
+
| "ENTRY"
|
|
263
|
+
| "EVALUATE"
|
|
264
|
+
| "EXEC"
|
|
265
|
+
| "EXIT"
|
|
266
|
+
| "GOBACK"
|
|
267
|
+
| "GO"
|
|
268
|
+
| "IF"
|
|
269
|
+
| "INITIALIZE"
|
|
270
|
+
| "INSPECT"
|
|
271
|
+
| "MERGE"
|
|
272
|
+
| "MOVE"
|
|
273
|
+
| "MULTIPLY"
|
|
274
|
+
| "OPEN"
|
|
275
|
+
| "PERFORM"
|
|
276
|
+
| "PROCEDURE"
|
|
277
|
+
| "READ"
|
|
278
|
+
| "RELEASE"
|
|
279
|
+
| "RETURN"
|
|
280
|
+
| "REWRITE"
|
|
281
|
+
| "SEARCH"
|
|
282
|
+
| "SORT"
|
|
283
|
+
| "START"
|
|
284
|
+
| "STOP"
|
|
285
|
+
| "STRING"
|
|
286
|
+
| "SUBTRACT"
|
|
287
|
+
| "UNSTRING"
|
|
288
|
+
| "USE"
|
|
289
|
+
| "WHEN"
|
|
290
|
+
| "WRITE"
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
fn parse_exec_sql(
|
|
295
|
+
tokens: &[Token],
|
|
296
|
+
line_start_offset: usize,
|
|
297
|
+
byte_len: usize,
|
|
298
|
+
caller_name: Option<String>,
|
|
299
|
+
) -> Option<(ParsedExternalOp, Vec<ParsedIdentifier>, Vec<ParsedLiteral>)> {
|
|
300
|
+
if !has_two_tokens(tokens, "EXEC", "SQL") {
|
|
301
|
+
return None;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let verb = tokens[2..]
|
|
305
|
+
.iter()
|
|
306
|
+
.find(|t| {
|
|
307
|
+
matches!(
|
|
308
|
+
t.text.as_str(),
|
|
309
|
+
"SELECT" | "INSERT" | "UPDATE" | "DELETE" | "MERGE"
|
|
310
|
+
)
|
|
311
|
+
})
|
|
312
|
+
.map(|t| t.text.clone())
|
|
313
|
+
.unwrap_or_else(|| "SQL".to_string());
|
|
314
|
+
let target = extract_sql_target(tokens, &verb);
|
|
315
|
+
let mut identifiers = Vec::new();
|
|
316
|
+
if let Some(ref table) = target {
|
|
317
|
+
identifiers.push(ParsedIdentifier {
|
|
318
|
+
caller_name: caller_name.clone(),
|
|
319
|
+
kind: "sql_table".to_string(),
|
|
320
|
+
value: table.clone(),
|
|
321
|
+
start_offset: line_start_offset,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
let literals = tokens
|
|
325
|
+
.iter()
|
|
326
|
+
.filter(|t| t.quoted)
|
|
327
|
+
.map(|t| ParsedLiteral {
|
|
328
|
+
caller_name: caller_name.clone(),
|
|
329
|
+
kind: "string_literal".to_string(),
|
|
330
|
+
value: clean_name(&t.text),
|
|
331
|
+
start_offset: line_start_offset + t.start,
|
|
332
|
+
})
|
|
333
|
+
.collect::<Vec<_>>();
|
|
334
|
+
|
|
335
|
+
Some((
|
|
336
|
+
ParsedExternalOp {
|
|
337
|
+
caller_name,
|
|
338
|
+
kind: ExternalOpKind::ExecSql,
|
|
339
|
+
verb,
|
|
340
|
+
target,
|
|
341
|
+
start_offset: line_start_offset,
|
|
342
|
+
byte_len,
|
|
343
|
+
},
|
|
344
|
+
identifiers,
|
|
345
|
+
literals,
|
|
346
|
+
))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fn extract_sql_target(tokens: &[Token], verb: &str) -> Option<String> {
|
|
350
|
+
match verb {
|
|
351
|
+
"SELECT" => token_after_keyword(tokens, "FROM"),
|
|
352
|
+
"INSERT" => token_after_keyword(tokens, "INTO"),
|
|
353
|
+
"UPDATE" => token_after_keyword(tokens, "UPDATE"),
|
|
354
|
+
"DELETE" => token_after_keyword(tokens, "FROM"),
|
|
355
|
+
"MERGE" => {
|
|
356
|
+
token_after_keyword(tokens, "INTO").or_else(|| token_after_keyword(tokens, "USING"))
|
|
357
|
+
}
|
|
358
|
+
_ => None,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
fn parse_exec_cics(
|
|
363
|
+
tokens: &[Token],
|
|
364
|
+
line_start_offset: usize,
|
|
365
|
+
byte_len: usize,
|
|
366
|
+
caller_name: Option<String>,
|
|
367
|
+
) -> Option<(ParsedExternalOp, Vec<ParsedLiteral>)> {
|
|
368
|
+
if !has_two_tokens(tokens, "EXEC", "CICS") {
|
|
369
|
+
return None;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let verb = tokens
|
|
373
|
+
.get(2)
|
|
374
|
+
.map(|t| clean_name(&t.text))
|
|
375
|
+
.filter(|s| !s.is_empty())
|
|
376
|
+
.unwrap_or_else(|| "CICS".to_string());
|
|
377
|
+
let target_idx = tokens.iter().position(|t| t.text == "PROGRAM");
|
|
378
|
+
let target = target_idx
|
|
379
|
+
.and_then(|idx| tokens.get(idx + 1))
|
|
380
|
+
.map(|t| clean_name(&t.text))
|
|
381
|
+
.filter(|s| !s.is_empty());
|
|
382
|
+
let literals = target_idx
|
|
383
|
+
.and_then(|idx| tokens.get(idx + 1))
|
|
384
|
+
.filter(|t| t.quoted)
|
|
385
|
+
.map(|t| ParsedLiteral {
|
|
386
|
+
caller_name: caller_name.clone(),
|
|
387
|
+
kind: "exec_cics_program".to_string(),
|
|
388
|
+
value: clean_name(&t.text),
|
|
389
|
+
start_offset: line_start_offset + t.start,
|
|
390
|
+
})
|
|
391
|
+
.into_iter()
|
|
392
|
+
.collect::<Vec<_>>();
|
|
393
|
+
|
|
394
|
+
Some((
|
|
395
|
+
ParsedExternalOp {
|
|
396
|
+
caller_name,
|
|
397
|
+
kind: ExternalOpKind::ExecCics,
|
|
398
|
+
verb,
|
|
399
|
+
target,
|
|
400
|
+
start_offset: line_start_offset,
|
|
401
|
+
byte_len,
|
|
402
|
+
},
|
|
403
|
+
literals,
|
|
404
|
+
))
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fn parse_file_io(tokens: &[Token]) -> Option<(String, String)> {
|
|
408
|
+
match tokens.first()?.text.as_str() {
|
|
409
|
+
"OPEN" => {
|
|
410
|
+
let mode_idx = tokens
|
|
411
|
+
.iter()
|
|
412
|
+
.position(|t| matches!(t.text.as_str(), "INPUT" | "OUTPUT" | "I-O" | "EXTEND"))?;
|
|
413
|
+
let target = tokens.get(mode_idx + 1).map(|t| clean_name(&t.text))?;
|
|
414
|
+
(!target.is_empty()).then_some(("OPEN".to_string(), target))
|
|
415
|
+
}
|
|
416
|
+
"READ" | "WRITE" | "REWRITE" | "DELETE" | "START" | "CLOSE" | "RETURN" | "RELEASE" => {
|
|
417
|
+
let target = tokens.get(1).map(|t| clean_name(&t.text))?;
|
|
418
|
+
(!target.is_empty()).then_some((tokens[0].text.clone(), target))
|
|
419
|
+
}
|
|
420
|
+
_ => None,
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
fn token_after_keyword(tokens: &[Token], keyword: &str) -> Option<String> {
|
|
425
|
+
let idx = tokens.iter().position(|t| t.text == keyword)?;
|
|
426
|
+
tokens
|
|
427
|
+
.get(idx + 1)
|
|
428
|
+
.map(|t| clean_name(&t.text))
|
|
429
|
+
.filter(|s| !s.is_empty())
|
|
430
|
+
}
|
package/src/lib.rs
CHANGED
package/src/main.rs
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
use std::error::Error;
|
|
2
|
+
use std::path::{Path, PathBuf};
|
|
3
|
+
|
|
4
|
+
type MemResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
|
5
|
+
|
|
6
|
+
/// Codex-style: consolidate when cumulative session tokens exceed this.
|
|
7
|
+
pub const TOKEN_SUMMARY_THRESHOLD: u32 = 8_000;
|
|
8
|
+
|
|
9
|
+
/// Codex injects ~5k tokens of `memory_summary.md`; ~4 chars/token.
|
|
10
|
+
pub const MEMORY_SUMMARY_INJECT_MAX_CHARS: usize = 20_000;
|
|
11
|
+
|
|
12
|
+
pub const MEMORY_SUMMARY_FILE: &str = "memory_summary.md";
|
|
13
|
+
pub const MEMORY_HANDBOOK_FILE: &str = "MEMORY.md";
|
|
14
|
+
pub const RAW_MEMORIES_FILE: &str = "raw_memories.md";
|
|
15
|
+
pub const AGENTS_FILE: &str = "AGENTS.md";
|
|
16
|
+
pub const LEGACY_SUMMARY_FILE: &str = "SUMMARY_MEM.md";
|
|
17
|
+
|
|
18
|
+
pub const CONSOLIDATION_SUMMARY_MARKER: &str = "---COBOLX_MEMORY_SUMMARY---";
|
|
19
|
+
pub const CONSOLIDATION_HANDBOOK_MARKER: &str = "---COBOLX_MEMORY_HANDBOOK---";
|
|
20
|
+
|
|
21
|
+
/// Codex-aligned memory layout under `.cobolx/memories/`.
|
|
22
|
+
pub struct CodexMemories {
|
|
23
|
+
memories_dir: PathBuf,
|
|
24
|
+
rollout_summaries_dir: PathBuf,
|
|
25
|
+
project_root: PathBuf,
|
|
26
|
+
legacy_summary_path: PathBuf,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl CodexMemories {
|
|
30
|
+
pub fn for_project(base_dir: &Path, project_root: &Path) -> Self {
|
|
31
|
+
let memories_dir = base_dir.join("memories");
|
|
32
|
+
let rollout_summaries_dir = memories_dir.join("rollout_summaries");
|
|
33
|
+
Self {
|
|
34
|
+
memories_dir,
|
|
35
|
+
rollout_summaries_dir,
|
|
36
|
+
project_root: project_root.to_path_buf(),
|
|
37
|
+
legacy_summary_path: base_dir.join(LEGACY_SUMMARY_FILE),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub fn memories_dir(&self) -> &Path {
|
|
42
|
+
&self.memories_dir
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pub fn rollout_summaries_dir(&self) -> &Path {
|
|
46
|
+
&self.rollout_summaries_dir
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pub fn ensure_layout(&self) -> MemResult<()> {
|
|
50
|
+
std::fs::create_dir_all(&self.memories_dir)?;
|
|
51
|
+
std::fs::create_dir_all(&self.rollout_summaries_dir)?;
|
|
52
|
+
|
|
53
|
+
let summary_path = self.memory_summary_path();
|
|
54
|
+
if !summary_path.exists() {
|
|
55
|
+
if self.legacy_summary_path.exists() {
|
|
56
|
+
let legacy = std::fs::read_to_string(&self.legacy_summary_path)?;
|
|
57
|
+
std::fs::write(&summary_path, legacy)?;
|
|
58
|
+
} else {
|
|
59
|
+
std::fs::write(&summary_path, default_memory_summary())?;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let handbook_path = self.memory_handbook_path();
|
|
64
|
+
if !handbook_path.exists() {
|
|
65
|
+
std::fs::write(&handbook_path, default_memory_handbook())?;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
Ok(())
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pub fn read_agents_instructions(&self) -> Option<String> {
|
|
72
|
+
let path = self.project_root.join(AGENTS_FILE);
|
|
73
|
+
if path.exists() {
|
|
74
|
+
std::fs::read_to_string(path).ok()
|
|
75
|
+
} else {
|
|
76
|
+
None
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Short summary injected each prompt (Codex: `memory_summary.md`, capped).
|
|
81
|
+
pub fn read_memory_summary_for_injection(&self) -> MemResult<String> {
|
|
82
|
+
let raw = std::fs::read_to_string(self.memory_summary_path())?;
|
|
83
|
+
Ok(truncate_utf8_prefix(&raw, MEMORY_SUMMARY_INJECT_MAX_CHARS))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pub fn read_memory_summary(&self) -> MemResult<String> {
|
|
87
|
+
Ok(std::fs::read_to_string(self.memory_summary_path())?)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pub fn read_memory_handbook(&self) -> MemResult<String> {
|
|
91
|
+
Ok(std::fs::read_to_string(self.memory_handbook_path())?)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Phase 1: per-run recap (Codex: `rollout_summaries/{id}.md`).
|
|
95
|
+
pub fn write_rollout_summary(&self, run_id: &str, content: &str) -> MemResult<PathBuf> {
|
|
96
|
+
let path = self.rollout_summaries_dir.join(format!("{}.md", run_id));
|
|
97
|
+
std::fs::write(&path, content)?;
|
|
98
|
+
Ok(path)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
pub fn write_raw_memories(&self, content: &str) -> MemResult<()> {
|
|
102
|
+
std::fs::write(self.memories_dir.join(RAW_MEMORIES_FILE), content)?;
|
|
103
|
+
Ok(())
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pub fn write_consolidated(&self, memory_summary: &str, memory_handbook: &str) -> MemResult<()> {
|
|
107
|
+
std::fs::write(self.memory_summary_path(), memory_summary)?;
|
|
108
|
+
std::fs::write(self.memory_handbook_path(), memory_handbook)?;
|
|
109
|
+
Ok(())
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
pub fn parse_consolidation_output(output: &str) -> Option<(String, String)> {
|
|
113
|
+
let summary_start = output.find(CONSOLIDATION_SUMMARY_MARKER)?;
|
|
114
|
+
let handbook_start = output.find(CONSOLIDATION_HANDBOOK_MARKER)?;
|
|
115
|
+
if handbook_start <= summary_start {
|
|
116
|
+
return None;
|
|
117
|
+
}
|
|
118
|
+
let summary = output[summary_start + CONSOLIDATION_SUMMARY_MARKER.len()..handbook_start]
|
|
119
|
+
.trim()
|
|
120
|
+
.to_string();
|
|
121
|
+
let handbook = output[handbook_start + CONSOLIDATION_HANDBOOK_MARKER.len()..]
|
|
122
|
+
.trim()
|
|
123
|
+
.to_string();
|
|
124
|
+
if summary.is_empty() || handbook.is_empty() {
|
|
125
|
+
return None;
|
|
126
|
+
}
|
|
127
|
+
Some((summary, handbook))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fn memory_summary_path(&self) -> PathBuf {
|
|
131
|
+
self.memories_dir.join(MEMORY_SUMMARY_FILE)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fn memory_handbook_path(&self) -> PathBuf {
|
|
135
|
+
self.memories_dir.join(MEMORY_HANDBOOK_FILE)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pub fn default_memory_summary() -> String {
|
|
140
|
+
"# COBOLX Memory Summary\n\n\
|
|
141
|
+
Navigational summary for the next session (Codex-style `memory_summary.md`).\n\n\
|
|
142
|
+
- last_updated: (none)\n\
|
|
143
|
+
- tokens_summarized: 0\n\n\
|
|
144
|
+
## Context\n\n\
|
|
145
|
+
(none yet)\n\n\
|
|
146
|
+
## Key findings\n\n\
|
|
147
|
+
(none yet)\n"
|
|
148
|
+
.to_string()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
pub fn default_memory_handbook() -> String {
|
|
152
|
+
"# COBOLX Memory Handbook\n\n\
|
|
153
|
+
Long-form project memory (Codex-style `MEMORY.md`). You may edit manually.\n\n\
|
|
154
|
+
## Project\n\n\
|
|
155
|
+
(none yet)\n\n\
|
|
156
|
+
## COBOL programs\n\n\
|
|
157
|
+
(none yet)\n\n\
|
|
158
|
+
## Open questions\n\n\
|
|
159
|
+
(none yet)\n"
|
|
160
|
+
.to_string()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn truncate_utf8_prefix(content: &str, max_bytes: usize) -> String {
|
|
164
|
+
if content.len() <= max_bytes {
|
|
165
|
+
return content.to_string();
|
|
166
|
+
}
|
|
167
|
+
let mut end = max_bytes;
|
|
168
|
+
while end > 0 && !content.is_char_boundary(end) {
|
|
169
|
+
end -= 1;
|
|
170
|
+
}
|
|
171
|
+
format!("{}…\n\n[truncated for context budget]", &content[..end])
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#[cfg(test)]
|
|
175
|
+
mod tests {
|
|
176
|
+
use super::*;
|
|
177
|
+
use tempfile::tempdir;
|
|
178
|
+
|
|
179
|
+
#[test]
|
|
180
|
+
fn codex_memories_layout_and_rollout_summary() {
|
|
181
|
+
let dir = tempdir().unwrap();
|
|
182
|
+
let base = dir.path().join(".cobolx");
|
|
183
|
+
let mem = CodexMemories::for_project(&base, dir.path());
|
|
184
|
+
mem.ensure_layout().unwrap();
|
|
185
|
+
|
|
186
|
+
assert!(mem.memories_dir().join(MEMORY_SUMMARY_FILE).exists());
|
|
187
|
+
assert!(mem.memories_dir().join(MEMORY_HANDBOOK_FILE).exists());
|
|
188
|
+
|
|
189
|
+
mem.write_rollout_summary("20250626T120000", "# rollout recap\n")
|
|
190
|
+
.unwrap();
|
|
191
|
+
assert!(
|
|
192
|
+
mem.rollout_summaries_dir()
|
|
193
|
+
.join("20250626T120000.md")
|
|
194
|
+
.exists()
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#[test]
|
|
199
|
+
fn parses_consolidation_markers() {
|
|
200
|
+
let out = format!(
|
|
201
|
+
"preamble\n{}\nsummary body\n{}\nhandbook body",
|
|
202
|
+
CONSOLIDATION_SUMMARY_MARKER, CONSOLIDATION_HANDBOOK_MARKER
|
|
203
|
+
);
|
|
204
|
+
let (s, h) = CodexMemories::parse_consolidation_output(&out).unwrap();
|
|
205
|
+
assert_eq!(s, "summary body");
|
|
206
|
+
assert_eq!(h, "handbook body");
|
|
207
|
+
}
|
|
208
|
+
}
|