agent-dump 1.2.0__tar.gz → 1.2.2__tar.gz

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.
Files changed (119) hide show
  1. {agent_dump-1.2.0 → agent_dump-1.2.2}/Cargo.lock +1 -1
  2. {agent_dump-1.2.0 → agent_dump-1.2.2}/Cargo.toml +1 -1
  3. {agent_dump-1.2.0 → agent_dump-1.2.2}/PKG-INFO +2 -2
  4. {agent_dump-1.2.0 → agent_dump-1.2.2}/README.md +1 -1
  5. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/Cargo.toml +1 -1
  6. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/compat/json.rs +2 -2
  7. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/render.rs +15 -1
  8. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/claude/mod.rs +12 -1
  9. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/codex/mod.rs +16 -1
  10. agent_dump-1.2.2/crates/agent-dump-core/src/providers/jsonl.rs +275 -0
  11. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/pi/mod.rs +1 -1
  12. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/context.rs +28 -10
  13. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/index/tests.rs +27 -0
  14. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/index.rs +25 -26
  15. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/text.rs +72 -17
  16. {agent_dump-1.2.0 → agent_dump-1.2.2}/pyproject.toml +2 -0
  17. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/locales/en.json +0 -1
  18. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/locales/zh.json +0 -1
  19. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/insight-handoff.txt +2 -2
  20. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/pm-handoff.txt +2 -2
  21. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/events.rs +6 -10
  22. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/handoff.rs +1 -2
  23. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/sessions.rs +3 -10
  24. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/terminal/reader.rs +50 -26
  25. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/collect.rs +3 -31
  26. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/interactive.rs +116 -58
  27. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/list.rs +1 -1
  28. agent_dump-1.2.0/crates/agent-dump-core/src/providers/jsonl.rs +0 -139
  29. {agent_dump-1.2.0 → agent_dump-1.2.2}/LICENSE +0 -0
  30. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/compat/json/diagnostic.rs +0 -0
  31. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/compat/json/encoding.rs +0 -0
  32. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/compat/mod.rs +0 -0
  33. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/compat/value.rs +0 -0
  34. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/config.rs +0 -0
  35. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/lib.rs +0 -0
  36. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/diagnostics.rs +0 -0
  37. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/export.rs +0 -0
  38. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/formats.rs +0 -0
  39. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/i18n.rs +0 -0
  40. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/output/mod.rs +0 -0
  41. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/parallel.rs +0 -0
  42. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/claude/transcript.rs +0 -0
  43. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/codex/enrichment.rs +0 -0
  44. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/codex/patch.rs +0 -0
  45. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/codex/transcript.rs +0 -0
  46. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/contract.rs +0 -0
  47. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/cursor/mod.rs +0 -0
  48. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/cursor/transcript.rs +0 -0
  49. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/desktop/cherry.rs +0 -0
  50. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/desktop/deepchat.rs +0 -0
  51. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/desktop/minimax.rs +0 -0
  52. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/desktop/mod.rs +0 -0
  53. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/desktop/tests.rs +0 -0
  54. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/error.rs +0 -0
  55. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/files.rs +0 -0
  56. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/kimi/mod.rs +0 -0
  57. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/kimi/transcript.rs +0 -0
  58. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/kimi/wire.rs +0 -0
  59. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/mod.rs +0 -0
  60. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/pi/transcript.rs +0 -0
  61. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/registry.rs +0 -0
  62. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/source_tests.rs +0 -0
  63. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/sqlite/connection.rs +0 -0
  64. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/sqlite/legacy.rs +0 -0
  65. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/sqlite/mod.rs +0 -0
  66. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/sqlite/v2.rs +0 -0
  67. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/providers/title.rs +0 -0
  68. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/filter.rs +0 -0
  69. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/mod.rs +0 -0
  70. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/read.rs +0 -0
  71. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/scanner.rs +0 -0
  72. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/query/transcript.rs +0 -0
  73. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/session/assembly.rs +0 -0
  74. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/session/cache/tests.rs +0 -0
  75. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/session/cache.rs +0 -0
  76. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/session/mod.rs +0 -0
  77. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/session/timestamp.rs +0 -0
  78. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/storage/mod.rs +0 -0
  79. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/storage/private_files.rs +0 -0
  80. {agent_dump-1.2.0 → agent_dump-1.2.2}/crates/agent-dump-core/src/storage/source_io.rs +0 -0
  81. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/.gitattributes +0 -0
  82. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/README.md +0 -0
  83. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/insight-chunk.txt +0 -0
  84. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/insight-merge.txt +0 -0
  85. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/insight-report.txt +0 -0
  86. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/insight-retry.txt +0 -0
  87. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/pm-chunk.txt +0 -0
  88. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/pm-merge.txt +0 -0
  89. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/pm-report.txt +0 -0
  90. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/pm-retry.txt +0 -0
  91. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/read-session-en.txt +0 -0
  92. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/read-session-zh.txt +0 -0
  93. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/system.txt +0 -0
  94. {agent_dump-1.2.0 → agent_dump-1.2.2}/resources/prompts/uri.txt +0 -0
  95. {agent_dump-1.2.0 → agent_dump-1.2.2}/rust-toolchain.toml +0 -0
  96. {agent_dump-1.2.0 → agent_dump-1.2.2}/rustfmt.toml +0 -0
  97. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/cli_args.rs +0 -0
  98. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/llm.rs +0 -0
  99. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/log.rs +0 -0
  100. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/mod.rs +0 -0
  101. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/model.rs +0 -0
  102. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/progress.rs +0 -0
  103. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/prompts.rs +0 -0
  104. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/reduction.rs +0 -0
  105. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/collect/summary.rs +0 -0
  106. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/command.rs +0 -0
  107. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/date_input.rs +0 -0
  108. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/main.rs +0 -0
  109. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/shortcut.rs +0 -0
  110. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/terminal/mod.rs +0 -0
  111. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/terminal/selector.rs +0 -0
  112. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/terminal/tui.rs +0 -0
  113. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/config.rs +0 -0
  114. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/machine.rs +0 -0
  115. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/maintenance.rs +0 -0
  116. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/mod.rs +0 -0
  117. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/read.rs +0 -0
  118. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/reader.rs +0 -0
  119. {agent_dump-1.2.0 → agent_dump-1.2.2}/src/workflows/uri.rs +0 -0
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10
10
 
11
11
  [[package]]
12
12
  name = "agent-dump"
13
- version = "1.2.0"
13
+ version = "1.2.2"
14
14
  dependencies = [
15
15
  "agent-dump-core",
16
16
  "caseless",
@@ -46,7 +46,7 @@ nix = { version = "0.31", default-features = false, features = ["fs", "user"] }
46
46
 
47
47
  [package]
48
48
  name = "agent-dump"
49
- version = "1.2.0"
49
+ version = "1.2.2"
50
50
  edition = "2024"
51
51
  rust-version = "1.90"
52
52
  publish = false
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dump
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Classifier: Environment :: Console
5
5
  Classifier: License :: OSI Approved :: MIT License
6
6
  Classifier: Programming Language :: Rust
@@ -462,7 +462,7 @@ File discovery failures include partially unreadable providers. Valid sessions r
462
462
 
463
463
  When some session reads or summaries fail, collection continues with successful sessions. The saved Markdown includes a fixed incomplete-report notice with failure and included-session counts; an entirely failed run still fails.
464
464
 
465
- Collect discovers candidate sessions without a creation-time cutoff and processes user/assistant visible text only within the requested local dates. A session spanning several days is split into separate daily units before summarization; session counts remain unique URI counts. Text without a reliable timestamp is excluded and reported as an incomplete date-coverage gap (including undated Kimi context and inferred Cursor times). No session creation/update time is substituted. Query filters select candidates; limits apply to unique sessions after text-date filtering. Events, including long individual messages, are split into chunks of at most 3,200 Unicode characters including labels. Chunk summaries are merged in groups of at most eight; large final reports are rendered in separate source-preserving groups. Model inputs are limited to 64,000 characters per request. Oversized metadata or derived summaries fail explicitly; source text is never silently truncated. Partial reports list omitted session URIs. Full input coverage does not mean a summary preserves every detail.
465
+ Collect discovers candidate sessions without a creation-time cutoff and processes user/assistant visible text only within the requested local dates. A session spanning several days is split into separate daily units before summarization; session counts remain unique URI counts. Text without a reliable timestamp (including undated Kimi context and inferred Cursor times) belongs to no date and is excluded without being reported as a coverage gap. No session creation/update time is substituted. Query filters select candidates; limits apply to unique sessions after text-date filtering. Events, including long individual messages, are split into chunks of at most 3,200 Unicode characters including labels. Chunk summaries are merged in groups of at most eight; large final reports are rendered in separate source-preserving groups. Model inputs are limited to 64,000 characters per request. Oversized metadata or derived summaries fail explicitly; source text is never silently truncated. Partial reports list omitted session URIs. Full input coverage does not mean a summary preserves every detail.
466
466
 
467
467
  PM summaries merge sessions only within the same date and known working directory. Sessions without a working directory retain separate attribution.
468
468
 
@@ -438,7 +438,7 @@ File discovery failures include partially unreadable providers. Valid sessions r
438
438
 
439
439
  When some session reads or summaries fail, collection continues with successful sessions. The saved Markdown includes a fixed incomplete-report notice with failure and included-session counts; an entirely failed run still fails.
440
440
 
441
- Collect discovers candidate sessions without a creation-time cutoff and processes user/assistant visible text only within the requested local dates. A session spanning several days is split into separate daily units before summarization; session counts remain unique URI counts. Text without a reliable timestamp is excluded and reported as an incomplete date-coverage gap (including undated Kimi context and inferred Cursor times). No session creation/update time is substituted. Query filters select candidates; limits apply to unique sessions after text-date filtering. Events, including long individual messages, are split into chunks of at most 3,200 Unicode characters including labels. Chunk summaries are merged in groups of at most eight; large final reports are rendered in separate source-preserving groups. Model inputs are limited to 64,000 characters per request. Oversized metadata or derived summaries fail explicitly; source text is never silently truncated. Partial reports list omitted session URIs. Full input coverage does not mean a summary preserves every detail.
441
+ Collect discovers candidate sessions without a creation-time cutoff and processes user/assistant visible text only within the requested local dates. A session spanning several days is split into separate daily units before summarization; session counts remain unique URI counts. Text without a reliable timestamp (including undated Kimi context and inferred Cursor times) belongs to no date and is excluded without being reported as a coverage gap. No session creation/update time is substituted. Query filters select candidates; limits apply to unique sessions after text-date filtering. Events, including long individual messages, are split into chunks of at most 3,200 Unicode characters including labels. Chunk summaries are merged in groups of at most eight; large final reports are rendered in separate source-preserving groups. Model inputs are limited to 64,000 characters per request. Oversized metadata or derived summaries fail explicitly; source text is never silently truncated. Partial reports list omitted session URIs. Full input coverage does not mean a summary preserves every detail.
442
442
 
443
443
  PM summaries merge sessions only within the same date and known working directory. Sessions without a working directory retain separate attribution.
444
444
 
@@ -15,7 +15,7 @@ workspace = true
15
15
  jiff.workspace = true
16
16
  md-5.workspace = true
17
17
  serde.workspace = true
18
- serde_json.workspace = true
18
+ serde_json = { workspace = true, features = ["raw_value"] }
19
19
  regex.workspace = true
20
20
  sha2.workspace = true
21
21
  tempfile.workspace = true
@@ -32,7 +32,7 @@ pub fn nonfinite(number: &Number) -> Option<f64> {
32
32
  }
33
33
  }
34
34
 
35
- fn normalize(value: &mut Value) {
35
+ pub(crate) fn normalize(value: &mut Value) {
36
36
  match value {
37
37
  Value::Number(number) if number.as_str().contains(['.', 'e', 'E']) => {
38
38
  if let Ok(value) = number.as_str().parse::<f64>()
@@ -50,7 +50,7 @@ fn normalize(value: &mut Value) {
50
50
  pub fn from_slice(bytes: &[u8]) -> crate::Result<Value> {
51
51
  let text = std::str::from_utf8(bytes)
52
52
  .map_err(|error| diagnostic::invalid_utf8(bytes, error))?;
53
- let original = serde_json::from_slice::<Value>(bytes);
53
+ let original = serde_json::from_str::<Value>(text);
54
54
  if let Ok(mut value) = original {
55
55
  normalize(&mut value);
56
56
  return Ok(value);
@@ -5,7 +5,10 @@ const fn unsafe_char(c: char) -> bool {
5
5
  matches!(c, '\u{0}'..='\u{8}' | '\u{b}'..='\u{1f}' | '\u{7f}'..='\u{9f}' | '\u{61c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
6
6
  }
7
7
  pub fn safe_body(text: &str) -> String {
8
- text.chars().filter(|c| !unsafe_char(*c)).collect()
8
+ let mut safe = String::with_capacity(text.len());
9
+ text.split(unsafe_char)
10
+ .for_each(|piece| safe.push_str(piece));
11
+ safe
9
12
  }
10
13
  pub fn safe_line(text: &str) -> String {
11
14
  let replaced: String = text
@@ -458,3 +461,14 @@ pub fn metadata_summary(session: &Session, scheme: &str, zh: bool) -> String {
458
461
  fields.push(format!("uri={scheme}://{}", session.id));
459
462
  safe_line(&fields.join(" | "))
460
463
  }
464
+
465
+ #[cfg(test)]
466
+ mod tests {
467
+ #[test]
468
+ fn safe_body_drops_terminal_controls_and_keeps_layout() {
469
+ assert_eq!(
470
+ super::safe_body("\x1b[2J测试\u{202e}\n\tmore\rtext\u{7f}\u{85}"),
471
+ "[2J测试\n\tmoretext"
472
+ );
473
+ }
474
+ }
@@ -13,6 +13,17 @@ use serde_json::Value;
13
13
  use std::collections::HashMap;
14
14
  use std::path::{Path, PathBuf};
15
15
 
16
+ static RECORD: jsonl::Shape = jsonl::Shape::Fields(&[
17
+ ("timestamp", jsonl::Shape::Leaf),
18
+ (
19
+ "message",
20
+ jsonl::Shape::Fields(&[
21
+ ("role", jsonl::Shape::Leaf),
22
+ ("model", jsonl::Shape::Leaf),
23
+ ]),
24
+ ),
25
+ ]);
26
+
16
27
  pub struct Claude {
17
28
  roots: SourceRoots,
18
29
  titles: HashMap<PathBuf, HashMap<String, Value>>,
@@ -117,7 +128,7 @@ impl Claude {
117
128
  }
118
129
 
119
130
  fn scan(path: &Path) -> crate::Result<jsonl::Metadata> {
120
- jsonl::metadata(path, 20)
131
+ jsonl::metadata(path, 20, Some(&RECORD))
121
132
  }
122
133
 
123
134
  fn parse(
@@ -14,6 +14,21 @@ use crate::session::{Session, SessionData, parse_timestamp};
14
14
  use std::collections::HashMap;
15
15
  use std::path::{Path, PathBuf};
16
16
 
17
+ static RECORD: jsonl::Shape = jsonl::Shape::Fields(&[
18
+ ("timestamp", jsonl::Shape::Leaf),
19
+ (
20
+ "payload",
21
+ jsonl::Shape::Fields(&[
22
+ ("type", jsonl::Shape::Leaf),
23
+ ("model", jsonl::Shape::Leaf),
24
+ (
25
+ "arguments",
26
+ jsonl::Shape::Fields(&[("model", jsonl::Shape::Leaf)]),
27
+ ),
28
+ ]),
29
+ ),
30
+ ]);
31
+
17
32
  pub struct Codex {
18
33
  roots: SourceRoots,
19
34
  titles: Option<HashMap<String, String>>,
@@ -79,7 +94,7 @@ impl Codex {
79
94
  }
80
95
 
81
96
  fn scan(path: &Path) -> crate::Result<jsonl::Metadata> {
82
- jsonl::metadata(path, 10)
97
+ jsonl::metadata(path, 10, Some(&RECORD))
83
98
  }
84
99
 
85
100
  fn parse(
@@ -0,0 +1,275 @@
1
+ use crate::providers::contract::{DiagnosticSink, RecoverableDiagnostic};
2
+ use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor};
3
+ use serde_json::value::RawValue;
4
+ use serde_json::{Map, Value};
5
+ use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
6
+ use std::path::Path;
7
+
8
+ pub const FULL_SCAN_LIMIT: u64 = 256 * 1024;
9
+ const WINDOW: u64 = 64 * 1024;
10
+
11
+ pub struct Metadata {
12
+ pub header: Option<Value>,
13
+ pub records: Vec<Value>,
14
+ pub tail: Option<Value>,
15
+ pub complete: bool,
16
+ }
17
+
18
+ /// Fields a provider reads from a record. `Fields` positions are only
19
+ /// indexed, so a non-object there is kept as null.
20
+ pub enum Shape {
21
+ Leaf,
22
+ Fields(&'static [(&'static str, Shape)]),
23
+ }
24
+
25
+ struct Pruned(&'static [(&'static str, Shape)]);
26
+
27
+ impl<'de> Visitor<'de> for Pruned {
28
+ type Value = Map<String, Value>;
29
+
30
+ fn expecting(
31
+ &self,
32
+ formatter: &mut std::fmt::Formatter<'_>,
33
+ ) -> std::fmt::Result {
34
+ formatter.write_str("a JSON object")
35
+ }
36
+
37
+ fn visit_map<A: MapAccess<'de>>(
38
+ self,
39
+ mut map: A,
40
+ ) -> Result<Self::Value, A::Error> {
41
+ let mut object = Map::new();
42
+ while let Some(key) = map.next_key::<String>()? {
43
+ if let Some((_, shape)) =
44
+ self.0.iter().find(|(name, _)| *name == key)
45
+ {
46
+ let raw = map.next_value::<&RawValue>()?;
47
+ let value = project(raw.get(), shape)
48
+ .map_err(serde::de::Error::custom)?;
49
+ object.insert(key, value);
50
+ } else {
51
+ map.next_value::<IgnoredAny>()?;
52
+ }
53
+ }
54
+ Ok(object)
55
+ }
56
+ }
57
+
58
+ fn project(text: &str, shape: &'static Shape) -> serde_json::Result<Value> {
59
+ match shape {
60
+ Shape::Leaf => serde_json::from_str(text),
61
+ Shape::Fields(fields) if text.starts_with('{') => {
62
+ let mut deserializer = serde_json::Deserializer::from_str(text);
63
+ let object = deserializer.deserialize_map(Pruned(fields))?;
64
+ deserializer.end()?;
65
+ Ok(Value::Object(object))
66
+ }
67
+ Shape::Fields(_) => Ok(Value::Null),
68
+ }
69
+ }
70
+
71
+ fn object(bytes: &[u8]) -> Option<Value> {
72
+ crate::compat::json::from_slice(bytes)
73
+ .ok()
74
+ .filter(Value::is_object)
75
+ }
76
+
77
+ fn pruned(bytes: &[u8], shape: &'static Shape) -> Option<Value> {
78
+ let text = std::str::from_utf8(bytes).ok()?;
79
+ let text = text.trim_start_matches([' ', '\t', '\n', '\r']);
80
+ if !text.starts_with('{') {
81
+ return None;
82
+ }
83
+ // Inputs serde_json rejects, such as NaN, take the compatible path.
84
+ let Ok(mut value) = project(text, shape) else {
85
+ return object(bytes);
86
+ };
87
+ crate::compat::json::normalize(&mut value);
88
+ Some(value)
89
+ }
90
+
91
+ fn nonempty(bytes: &[u8]) -> bool {
92
+ bytes.iter().any(|byte| !byte.is_ascii_whitespace())
93
+ }
94
+
95
+ /// Records after the first `head_lines` keep only the fields in `shape`.
96
+ pub fn metadata(
97
+ path: &Path,
98
+ head_lines: usize,
99
+ shape: Option<&'static Shape>,
100
+ ) -> crate::Result<Metadata> {
101
+ let mut file = crate::storage::source_io::open(path)?;
102
+ let size = file.metadata()?.len();
103
+ let mut bytes = Vec::new();
104
+ if size <= FULL_SCAN_LIMIT {
105
+ crate::storage::source_io::at(path, file.read_to_end(&mut bytes))?;
106
+ let lines: Vec<_> = bytes
107
+ .split(|byte| *byte == b'\n')
108
+ .filter(|line| nonempty(line))
109
+ .collect();
110
+ let mut records = Vec::new();
111
+ for line in &lines {
112
+ records.extend(match shape {
113
+ Some(shape) if records.len() >= head_lines => {
114
+ pruned(line, shape)
115
+ }
116
+ _ => object(line),
117
+ });
118
+ }
119
+ return Ok(Metadata {
120
+ header: lines.first().and_then(|line| object(line)),
121
+ records,
122
+ tail: None,
123
+ complete: true,
124
+ });
125
+ }
126
+ crate::storage::source_io::at(
127
+ path,
128
+ file.by_ref().take(WINDOW).read_to_end(&mut bytes),
129
+ )?;
130
+ let end = bytes.iter().rposition(|byte| *byte == b'\n');
131
+ let lines: Vec<_> = end
132
+ .map(|end| {
133
+ bytes[..=end]
134
+ .split(|byte| *byte == b'\n')
135
+ .take(head_lines)
136
+ .filter(|line| nonempty(line))
137
+ .collect()
138
+ })
139
+ .unwrap_or_default();
140
+ let header = if end.is_none() {
141
+ Some(serde_json::json!({}))
142
+ } else {
143
+ lines.first().and_then(|line| object(line))
144
+ };
145
+ let records = lines.iter().filter_map(|line| object(line)).collect();
146
+ file.seek(SeekFrom::Start(size - WINDOW))?;
147
+ let mut tail = Vec::new();
148
+ file.take(WINDOW).read_to_end(&mut tail)?;
149
+ let tail_record =
150
+ tail.iter()
151
+ .position(|byte| *byte == b'\n')
152
+ .and_then(|start| {
153
+ let end = tail.iter().rposition(|byte| *byte == b'\n')?;
154
+ tail[start + 1..=end]
155
+ .split(|byte| *byte == b'\n')
156
+ .rev()
157
+ .find(|line| nonempty(line))
158
+ .and_then(object)
159
+ });
160
+ Ok(Metadata {
161
+ header,
162
+ records,
163
+ tail: tail_record,
164
+ complete: false,
165
+ })
166
+ }
167
+
168
+ pub fn scan(
169
+ path: &Path,
170
+ diagnostics: &mut DiagnosticSink<'_>,
171
+ mut append: impl FnMut(Value, &mut DiagnosticSink<'_>) -> crate::Result<()>,
172
+ ) -> crate::Result<()> {
173
+ scan_numbered(path, diagnostics, |_, record, diagnostics| {
174
+ append(record, diagnostics)
175
+ })
176
+ }
177
+
178
+ pub fn scan_numbered(
179
+ path: &Path,
180
+ diagnostics: &mut DiagnosticSink<'_>,
181
+ mut append: impl FnMut(
182
+ usize,
183
+ Value,
184
+ &mut DiagnosticSink<'_>,
185
+ ) -> crate::Result<()>,
186
+ ) -> crate::Result<()> {
187
+ let mut reader = BufReader::new(crate::storage::source_io::open(path)?);
188
+ let mut line = Vec::new();
189
+ let mut number = 0;
190
+ let mut skipped = Vec::new();
191
+ let mut count = 0;
192
+ loop {
193
+ line.clear();
194
+ if crate::storage::source_io::at(
195
+ path,
196
+ reader.read_until(b'\n', &mut line),
197
+ )? == 0
198
+ {
199
+ break;
200
+ }
201
+ number += 1;
202
+ if !nonempty(&line) {
203
+ continue;
204
+ }
205
+ if let Some(value) = object(&line) {
206
+ append(number, value, diagnostics)?;
207
+ } else if matches!(line.last(), Some(b'\n' | b'\r')) {
208
+ count += 1;
209
+ if skipped.len() < 5 {
210
+ skipped.push(number);
211
+ }
212
+ }
213
+ }
214
+ if count > 0 {
215
+ diagnostics(RecoverableDiagnostic::JsonlRecordsSkipped {
216
+ path: path.to_owned(),
217
+ count,
218
+ lines: skipped,
219
+ })?;
220
+ }
221
+ Ok(())
222
+ }
223
+
224
+ #[cfg(test)]
225
+ mod tests {
226
+ use super::*;
227
+
228
+ static SHAPE: Shape = Shape::Fields(&[
229
+ ("timestamp", Shape::Leaf),
230
+ (
231
+ "payload",
232
+ Shape::Fields(&[
233
+ ("type", Shape::Leaf),
234
+ ("arguments", Shape::Fields(&[("model", Shape::Leaf)])),
235
+ ]),
236
+ ),
237
+ ]);
238
+
239
+ #[test]
240
+ fn pruned_records_read_like_full_records() {
241
+ let lines: [&[u8]; 12] = [
242
+ br#"{"timestamp":"t","payload":{"type":"message","content":"x"}}"#,
243
+ br#"{"timestamp":"t","payload":[{"type":"message"}]}"#,
244
+ br#"{"payload":{"type":["message"]}}"#,
245
+ br#"{"payload":{"type":{"kind":"message"}}}"#,
246
+ br#"{"timestamp":"t","payload":{"type":"message","n":NaN}}"#,
247
+ br#"{"timestamp":"a","timestamp":"b","payload":{"type":1}}"#,
248
+ br#" {"timestamp":"c","payload":{"arguments":"{}"}}"#,
249
+ br#"{"payload":{"arguments":{"model":"m"}},"timestamp":1e999}"#,
250
+ br#"[{"timestamp":"t"}]"#,
251
+ br#"{"timestamp":"t"} trailing"#,
252
+ b"{\"timestamp\":\"\xff\"}",
253
+ br#"{"timestamp":"t","payload":"text"}"#,
254
+ ];
255
+ for line in lines {
256
+ let full = object(line);
257
+ let pruned = pruned(line, &SHAPE);
258
+ assert_eq!(full.is_some(), pruned.is_some(), "{line:?}");
259
+ let (Some(full), Some(pruned)) = (full, pruned) else {
260
+ continue;
261
+ };
262
+ for path in [
263
+ &["timestamp"][..],
264
+ &["payload", "type"],
265
+ &["payload", "arguments", "model"],
266
+ ] {
267
+ let read = |value: &Value| {
268
+ path.iter()
269
+ .fold(value.clone(), |value, key| value[key].clone())
270
+ };
271
+ assert_eq!(read(&full), read(&pruned), "{line:?} {path:?}");
272
+ }
273
+ }
274
+ }
275
+ }
@@ -36,7 +36,7 @@ impl Pi {
36
36
  }
37
37
 
38
38
  fn scan(path: &Path) -> crate::Result<crate::providers::jsonl::Metadata> {
39
- crate::providers::jsonl::metadata(path, 20)
39
+ crate::providers::jsonl::metadata(path, 20, None)
40
40
  }
41
41
 
42
42
  fn parse(
@@ -45,17 +45,41 @@ pub fn locate(
45
45
  query: &Query,
46
46
  ) -> crate::Result<Vec<Location>> {
47
47
  let revision = revision(data)?;
48
+ Ok(evidence(data, query)
49
+ .into_iter()
50
+ .map(|(index, snippet)| {
51
+ let position = index + 1;
52
+ Location {
53
+ position,
54
+ locator: format!("{revision}:{position}"),
55
+ role: data.messages[index].role.clone(),
56
+ snippet,
57
+ }
58
+ })
59
+ .collect())
60
+ }
61
+
62
+ pub fn hits(data: &SessionData, query: &Query) -> Vec<usize> {
63
+ evidence(data, query)
64
+ .into_iter()
65
+ .map(|(index, _)| index)
66
+ .collect()
67
+ }
68
+
69
+ fn evidence(data: &SessionData, query: &Query) -> Vec<(usize, String)> {
48
70
  let text = TextQuery::new(
49
71
  query.keyword.as_deref().unwrap_or(""),
50
72
  crate::query::text::Mode::Terms,
51
73
  );
74
+ if text.literals.is_empty() {
75
+ return Vec::new();
76
+ }
52
77
  let terms: Vec<_> = text
53
78
  .literals
54
79
  .iter()
55
80
  .map(|term| TextQuery::new(term, crate::query::text::Mode::Phrase))
56
81
  .collect();
57
- Ok(data
58
- .messages
82
+ data.messages
59
83
  .iter()
60
84
  .enumerate()
61
85
  .filter_map(|(index, message)| {
@@ -69,15 +93,9 @@ pub fn locate(
69
93
  let content = searchable_message(message);
70
94
  let evidence =
71
95
  terms.iter().find_map(|term| term.find(&[&content]))?;
72
- let position = index + 1;
73
- Some(Location {
74
- position,
75
- locator: format!("{revision}:{position}"),
76
- role: message.role.clone(),
77
- snippet: evidence.snippet,
78
- })
96
+ Some((index, evidence.snippet))
79
97
  })
80
- .collect())
98
+ .collect()
81
99
  }
82
100
 
83
101
  pub fn window(
@@ -356,3 +356,30 @@ fn existing_normalized_text_copies_remain_usable() {
356
356
  .unwrap();
357
357
  assert_eq!(stored, "after 认 证");
358
358
  }
359
+
360
+ #[test]
361
+ fn updates_refresh_last_seen_only_after_a_day() {
362
+ let directory = tempfile::tempdir().unwrap();
363
+ let mut index =
364
+ SearchIndex::at(&directory.path().join("index.db")).unwrap();
365
+ let a = session(directory.path(), "a", 0);
366
+ let provider = Reader(|_: &Session| Ok("body".into()));
367
+ let seen = |index: &mut SearchIndex, value: Option<f64>| -> f64 {
368
+ if let Some(value) = value {
369
+ index
370
+ .connection
371
+ .execute("UPDATE index_state SET last_seen_at = ?", [value])
372
+ .unwrap();
373
+ update(index, &provider, std::slice::from_ref(&a));
374
+ }
375
+ index
376
+ .connection
377
+ .query_row("SELECT last_seen_at FROM index_state", [], |r| r.get(0))
378
+ .unwrap()
379
+ };
380
+ update(&mut index, &provider, std::slice::from_ref(&a));
381
+ let stale = 2.0f64.mul_add(-86400.0, now());
382
+ assert!(seen(&mut index, Some(stale)) > stale + 86400.0);
383
+ let recent = now() - 3600.0;
384
+ assert!((seen(&mut index, Some(recent)) - recent).abs() < f64::EPSILON);
385
+ }
@@ -213,11 +213,10 @@ impl SearchIndex {
213
213
  ) -> crate::Result<(usize, Vec<String>)> {
214
214
  let observed = now();
215
215
  let indexed: HashMap<String, IndexedRow> = self.connection.prepare("SELECT session_id, updated_signature, fts_rowid, indexed_at FROM index_state WHERE agent = ?")?.query_map([info.name], |r| Ok((r.get(0)?, IndexedRow { signature: r.get(1)?, rowid: r.get(2)?, observed: r.get(3)? })))?.collect::<Result<_, _>>()?;
216
- let transaction = self.connection.transaction()?;
217
- for session in sessions {
218
- transaction.execute("UPDATE index_state SET last_seen_at = MAX(last_seen_at, ?) WHERE agent = ? AND session_id = ?", params![observed, info.name, session.id])?;
219
- }
220
- transaction.commit()?;
216
+ let ids: Vec<_> = sessions.iter().map(|session| &session.id).collect();
217
+ // Rows expire after 30 unseen days. Refreshing at most daily lets
218
+ // repeated searches skip the write and its journal sync.
219
+ self.connection.execute("UPDATE index_state SET last_seen_at = ? WHERE agent = ? AND last_seen_at < ? AND session_id IN (SELECT value FROM json_each(?))", params![observed, info.name, observed - 86400.0, serde_json::to_string(&ids)?])?;
221
220
  let pending: Vec<_> = sessions
222
221
  .iter()
223
222
  .filter_map(|session| {
@@ -371,20 +370,10 @@ impl SearchIndex {
371
370
  let Some(table) = fts_table(query) else {
372
371
  return self.literal_search(query, keys);
373
372
  };
374
- // The contentless normalized table stores no text; its matches read
375
- // the raw trigram row.
376
- let (raw, fields) = if table == "sessions_fts" {
377
- (
378
- "JOIN sessions_fts_trigram raw ON raw.rowid = f.rowid",
379
- "raw.title, raw.content",
380
- )
381
- } else {
382
- ("", "f.title, f.content")
383
- };
384
373
  // Unary + keeps MATCH as the FTS scan instead of repeating it per key.
385
374
  // Filtering returned rows retains the global BM25 corpus.
386
375
  let sql = format!(
387
- "SELECT s.agent, s.session_id, {fields}, bm25({table}) FROM {table} f {raw} JOIN index_state s ON s.fts_rowid = f.rowid WHERE {table} MATCH ? AND +f.rowid IN (SELECT scoped.fts_rowid FROM json_each(?) scope JOIN index_state scoped ON scoped.agent = json_extract(scope.value, '$[0]') AND scoped.session_id = json_extract(scope.value, '$[1]')) ORDER BY bm25({table}), s.session_updated_at DESC, s.session_created_at DESC, s.agent, s.session_id"
376
+ "SELECT s.agent, s.session_id, f.rowid, bm25({table}) FROM {table} f JOIN index_state s ON s.fts_rowid = f.rowid WHERE {table} MATCH ? AND +f.rowid IN (SELECT scoped.fts_rowid FROM json_each(?) scope JOIN index_state scoped ON scoped.agent = json_extract(scope.value, '$[0]') AND scoped.session_id = json_extract(scope.value, '$[1]')) ORDER BY bm25({table}), s.session_updated_at DESC, s.session_created_at DESC, s.agent, s.session_id"
388
377
  );
389
378
  let expression = query
390
379
  .literals
@@ -399,23 +388,33 @@ impl SearchIndex {
399
388
  })
400
389
  .collect::<Vec<_>>()
401
390
  .join(" ");
402
- let mut statement = self.connection.prepare(&sql)?;
403
- let mut rows = statement
404
- .query(params![expression, serde_json::to_string(keys)?])?;
391
+ let mut ranked = self.connection.prepare(&sql)?;
392
+ let mut rows =
393
+ ranked.query(params![expression, serde_json::to_string(keys)?])?;
394
+ // Ranked rows carry no text, so sorting never spills whole bodies to
395
+ // temporary files. Both tables share rowids; the trigram row holds
396
+ // the text.
397
+ let mut text = self.connection.prepare(
398
+ "SELECT title, content FROM sessions_fts_trigram WHERE rowid = ?",
399
+ )?;
405
400
  let mut results = Vec::new();
406
401
  while let Some(row) = rows.next()? {
407
- let provider: String = row.get(0)?;
408
- let id: String = row.get(1)?;
409
- let title: String = row.get(2)?;
410
- let content: String = row.get(3)?;
402
+ let Some((title, content)) = text
403
+ .query_row([row.get::<_, i64>(2)?], |r| {
404
+ Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
405
+ })
406
+ .optional()?
407
+ else {
408
+ continue;
409
+ };
411
410
  let Some(evidence) = query.find(&[&title, &content]) else {
412
411
  continue;
413
412
  };
414
413
  results.push(SearchResult {
415
- provider,
416
- id,
414
+ provider: row.get(0)?,
415
+ id: row.get(1)?,
417
416
  snippet: evidence.snippet,
418
- rank: -row.get::<_, f64>(4)?,
417
+ rank: -row.get::<_, f64>(3)?,
419
418
  });
420
419
  }
421
420
  Ok(results)