7 Commits

Author SHA1 Message Date
Taylor Eernisse
b168a58134 fix(search): cap vector search k-value and add rowid assertion
The vector search multiplier could grow unbounded on documents with
many chunks, producing enormous k values that cause SQLite to scan
far more rows than necessary. Clamp the multiplier to [8, 200] and
cap k at 10,000 to prevent degenerate performance on large corpora.

Also adds a debug_assert in decode_rowid to catch negative rowids
early — these indicate a bug in the encoding pipeline and should
fail fast rather than silently produce garbage document IDs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:34:05 -05:00
Taylor Eernisse
b704e33188 feat(sync): surface MR diff fetch/fail counters in sync output
Adds mr_diffs_fetched and mr_diffs_failed fields to IngestResult and
SyncResult, threads them through the orchestrator aggregation, includes
them in the structured tracing span and human-readable sync summary.
Previously MR diff failures were silently swallowed — now they appear
alongside resource event counts for full pipeline observability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:33:53 -05:00
Taylor Eernisse
6e82f723c3 fix(ingestion): unify store + watermark + job-complete in single transaction
Previously, drain_resource_events, drain_mr_closes_issues, and
drain_mr_diffs each opened a transaction only for the job-complete +
watermark update, but the store operation ran outside that transaction.
If the process crashed between the store and the watermark update, data
would be persisted without the watermark advancing, causing silent
duplicates on the next sync.

Now each drain function opens the transaction before the store call and
commits it only after both the store and the watermark update succeed.
On error, the transaction is explicitly dropped so the connection is
not left in a half-committed state.

Also:
- store_resource_events no longer manages its own transaction; the caller
  passes in a connection (which is actually the transaction)
- upsert_mr_file_changes wraps DELETE + INSERT in a transaction internally
- reset_discussion_watermarks now also clears diffs_synced_for_updated_at
- Orchestrator error span now includes closes_issues_failed + mr_diffs_failed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:33:47 -05:00
Taylor Eernisse
940a96375a refactor(search): rename --after/--updated-after to --since/--updated-since
The --since naming is more intuitive (matches git log --since) and
consistent with the list commands which already use --since. Renames
the CLI flags, SearchCliFilters fields, SearchFilters fields,
autocorrect registry, and robot-docs manifest. No behavioral change.

Affected paths:
- cli/mod.rs: SearchArgs field + clap attribute rename
- cli/commands/search.rs: SearchCliFilters + run_search plumbing
- search/filters.rs: SearchFilters struct + apply_filters logic
- main.rs: handle_search + robot-docs JSON
- cli/autocorrect.rs: COMMAND_FLAGS entry for search

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:33:24 -05:00
Taylor Eernisse
7dd86d5433 fix(db): add missing schema_version insert to migration 019
Migration 019 created performance indexes but never recorded itself
in the schema_version table. Without this row the migration runner
considers the schema outdated and would attempt to re-apply. Adds
the standard INSERT INTO schema_version for version 19.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:33:13 -05:00
Taylor Eernisse
429c6f07d2 release: v0.5.0
Bump version from 0.1.0 to 0.5.0 to reflect the maturity of the CLI
after months of development — robot mode, search pipeline, ingestion
orchestrator, who commands, timeline pipeline, and embedding support
are all implemented and stable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:33:07 -05:00
Taylor Eernisse
754efa4369 chore: add /release skill for automated SemVer version bumps
Adds a Claude Code skill that automates the release workflow:
parse bump type (major/minor/patch), update Cargo.toml + Cargo.lock,
commit, and tag. Intentionally does not auto-push so the user
retains control over when releases go to the remote.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:33:02 -05:00
16 changed files with 388 additions and 93 deletions

View File

@@ -0,0 +1,106 @@
---
name: release
description: Bump version, tag, and prepare for next development cycle
version: 1.0.0
author: Taylor Eernisse
category: automation
tags: ["release", "versioning", "semver", "git"]
---
# Release
Automate SemVer version bumps for the `lore` CLI.
## Invocation
```
/release <type>
```
Where `<type>` is one of:
- **major** — breaking changes (0.5.0 -> 1.0.0)
- **minor** — new features (0.5.0 -> 0.6.0)
- **patch** / **hotfix** — bug fixes (0.5.0 -> 0.5.1)
If no type is provided, ask the user.
## Procedure
Follow these steps exactly. Do NOT skip any step.
### 1. Determine bump type
Parse the argument. Accept these aliases:
- `major`, `breaking` -> MAJOR
- `minor`, `feature`, `feat` -> MINOR
- `patch`, `hotfix`, `fix` -> PATCH
If the argument doesn't match, ask the user to clarify.
### 2. Read current version
Read `Cargo.toml` and extract the `version = "X.Y.Z"` line. Parse into major, minor, patch integers.
### 3. Compute new version
- MAJOR: `(major+1).0.0`
- MINOR: `major.(minor+1).0`
- PATCH: `major.minor.(patch+1)`
### 4. Check preconditions
Run `git status` and `git log --oneline -5`. Show the user:
- Current version: X.Y.Z
- New version: A.B.C
- Bump type: major/minor/patch
- Working tree status (clean or dirty)
- Last 5 commits (so they can confirm scope)
If the working tree is dirty, warn: "You have uncommitted changes. They will NOT be included in the release tag. Continue?"
Ask the user to confirm before proceeding.
### 5. Update Cargo.toml
Edit the `version = "..."` line in Cargo.toml to the new version.
### 6. Update Cargo.lock
Run `cargo check` to update Cargo.lock with the new version. This also verifies the project compiles.
### 7. Commit the version bump
```bash
git add Cargo.toml Cargo.lock
git commit -m "release: v{NEW_VERSION}"
```
### 8. Tag the release
```bash
git tag v{NEW_VERSION}
```
### 9. Report
Print a summary:
```
Release v{NEW_VERSION} created.
Previous: v{OLD_VERSION}
Bump: {type}
Tag: v{NEW_VERSION}
Commit: {short hash}
To push: git push && git push --tags
```
Do NOT push automatically. The user decides when to push.
## Examples
```
/release minor -> 0.5.0 -> 0.6.0
/release hotfix -> 0.5.0 -> 0.5.1
/release patch -> 0.5.0 -> 0.5.1
/release major -> 0.5.0 -> 1.0.0
```

2
Cargo.lock generated
View File

@@ -1106,7 +1106,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lore"
version = "0.1.0"
version = "0.5.0"
dependencies = [
"async-stream",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "lore"
version = "0.1.0"
version = "0.5.0"
edition = "2024"
description = "Gitlore - Local GitLab data management with semantic search"
authors = ["Taylor Eernisse"]

View File

@@ -11,3 +11,6 @@ CREATE INDEX IF NOT EXISTS idx_mrs_updated_at_desc
-- MRs already have idx_discussions_mr_resolved (migration 006).
CREATE INDEX IF NOT EXISTS idx_discussions_issue_resolved
ON discussions(issue_id, resolvable, resolved);
INSERT INTO schema_version (version, applied_at, description)
VALUES (19, strftime('%s', 'now') * 1000, 'List performance indexes');

View File

@@ -131,8 +131,8 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
"--project",
"--label",
"--path",
"--after",
"--updated-after",
"--since",
"--updated-since",
"--limit",
"--explain",
"--no-explain",
@@ -294,16 +294,33 @@ fn valid_flags_for(subcommand: Option<&str>) -> Vec<&'static str> {
/// Run the pre-clap correction pass on raw args.
///
/// When `strict` is true (robot mode), only deterministic corrections are applied
/// (single-dash long flags, case normalization). Fuzzy matching is disabled to
/// prevent misleading agents with speculative corrections.
///
/// Returns the (possibly modified) args and any corrections applied.
pub fn correct_args(raw: Vec<String>) -> CorrectionResult {
pub fn correct_args(raw: Vec<String>, strict: bool) -> CorrectionResult {
let subcommand = detect_subcommand(&raw);
let valid = valid_flags_for(subcommand);
let mut corrected = Vec::with_capacity(raw.len());
let mut corrections = Vec::new();
let mut past_terminator = false;
for arg in raw {
if let Some(fixed) = try_correct(&arg, &valid) {
// B1: Stop correcting after POSIX `--` option terminator
if arg == "--" {
past_terminator = true;
corrected.push(arg);
continue;
}
if past_terminator {
corrected.push(arg);
continue;
}
if let Some(fixed) = try_correct(&arg, &valid, strict) {
let s = fixed.corrected.clone();
corrections.push(fixed);
corrected.push(s);
@@ -318,13 +335,33 @@ pub fn correct_args(raw: Vec<String>) -> CorrectionResult {
}
}
/// Clap built-in flags that should never be corrected. These are handled by clap
/// directly and are not in our GLOBAL_FLAGS registry.
const CLAP_BUILTINS: &[&str] = &["--help", "--version"];
/// Try to correct a single arg. Returns `None` if no correction needed.
fn try_correct(arg: &str, valid_flags: &[&str]) -> Option<Correction> {
///
/// When `strict` is true, fuzzy matching is disabled — only deterministic
/// corrections (single-dash fix, case normalization) are applied.
fn try_correct(arg: &str, valid_flags: &[&str], strict: bool) -> Option<Correction> {
// Only attempt correction on flag-like args (starts with `-`)
if !arg.starts_with('-') {
return None;
}
// B2: Never correct clap built-in flags (--help, --version)
let flag_part_for_builtin = if let Some(eq_pos) = arg.find('=') {
&arg[..eq_pos]
} else {
arg
};
if CLAP_BUILTINS
.iter()
.any(|b| b.eq_ignore_ascii_case(flag_part_for_builtin))
{
return None;
}
// Skip short flags — they're unambiguous single chars (-p, -n, -v, -J)
// Also skip stacked short flags (-vvv)
if !arg.starts_with("--") {
@@ -371,8 +408,9 @@ fn try_correct(arg: &str, valid_flags: &[&str]) -> Option<Correction> {
});
}
// Try fuzzy on the single-dash candidate
if let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
// Try fuzzy on the single-dash candidate (skip in strict mode)
if !strict
&& let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
&& score >= FUZZY_FLAG_THRESHOLD
{
return Some(Correction {
@@ -415,8 +453,9 @@ fn try_correct(arg: &str, valid_flags: &[&str]) -> Option<Correction> {
});
}
// Rule 3: Fuzzy flag match — `--staate` -> `--state`
if let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
// Rule 3: Fuzzy flag match — `--staate` -> `--state` (skip in strict mode)
if !strict
&& let Some((best_flag, score)) = best_fuzzy_match(&lower, valid_flags)
&& score >= FUZZY_FLAG_THRESHOLD
{
let corrected = match value_suffix {
@@ -510,7 +549,7 @@ mod tests {
#[test]
fn single_dash_robot() {
let result = correct_args(args("lore -robot issues -n 5"));
let result = correct_args(args("lore -robot issues -n 5"), false);
assert_eq!(result.corrections.len(), 1);
assert_eq!(result.corrections[0].original, "-robot");
assert_eq!(result.corrections[0].corrected, "--robot");
@@ -523,7 +562,7 @@ mod tests {
#[test]
fn single_dash_state() {
let result = correct_args(args("lore --robot issues -state opened"));
let result = correct_args(args("lore --robot issues -state opened"), false);
assert_eq!(result.corrections.len(), 1);
assert_eq!(result.corrections[0].corrected, "--state");
}
@@ -532,7 +571,7 @@ mod tests {
#[test]
fn case_robot() {
let result = correct_args(args("lore --Robot issues"));
let result = correct_args(args("lore --Robot issues"), false);
assert_eq!(result.corrections.len(), 1);
assert_eq!(result.corrections[0].corrected, "--robot");
assert_eq!(
@@ -543,7 +582,7 @@ mod tests {
#[test]
fn case_state_upper() {
let result = correct_args(args("lore --robot issues --State opened"));
let result = correct_args(args("lore --robot issues --State opened"), false);
assert_eq!(result.corrections.len(), 1);
assert_eq!(result.corrections[0].corrected, "--state");
assert_eq!(
@@ -554,7 +593,7 @@ mod tests {
#[test]
fn case_all_upper() {
let result = correct_args(args("lore --ROBOT issues --STATE opened"));
let result = correct_args(args("lore --ROBOT issues --STATE opened"), false);
assert_eq!(result.corrections.len(), 2);
assert_eq!(result.corrections[0].corrected, "--robot");
assert_eq!(result.corrections[1].corrected, "--state");
@@ -564,7 +603,7 @@ mod tests {
#[test]
fn fuzzy_staate() {
let result = correct_args(args("lore --robot issues --staate opened"));
let result = correct_args(args("lore --robot issues --staate opened"), false);
assert_eq!(result.corrections.len(), 1);
assert_eq!(result.corrections[0].corrected, "--state");
assert_eq!(result.corrections[0].rule, CorrectionRule::FuzzyFlag);
@@ -572,7 +611,7 @@ mod tests {
#[test]
fn fuzzy_projct() {
let result = correct_args(args("lore --robot issues --projct group/repo"));
let result = correct_args(args("lore --robot issues --projct group/repo"), false);
assert_eq!(result.corrections.len(), 1);
assert_eq!(result.corrections[0].corrected, "--project");
assert_eq!(result.corrections[0].rule, CorrectionRule::FuzzyFlag);
@@ -583,7 +622,7 @@ mod tests {
#[test]
fn already_correct() {
let original = args("lore --robot issues --state opened -n 10");
let result = correct_args(original.clone());
let result = correct_args(original.clone(), false);
assert!(result.corrections.is_empty());
assert_eq!(result.args, original);
}
@@ -591,27 +630,27 @@ mod tests {
#[test]
fn short_flags_untouched() {
let original = args("lore -J issues -n 10 -s opened -p group/repo");
let result = correct_args(original.clone());
let result = correct_args(original.clone(), false);
assert!(result.corrections.is_empty());
}
#[test]
fn stacked_short_flags_untouched() {
let original = args("lore -vvv issues");
let result = correct_args(original.clone());
let result = correct_args(original.clone(), false);
assert!(result.corrections.is_empty());
}
#[test]
fn positional_args_untouched() {
let result = correct_args(args("lore --robot search authentication"));
let result = correct_args(args("lore --robot search authentication"), false);
assert!(result.corrections.is_empty());
}
#[test]
fn wildly_wrong_flag_not_corrected() {
// `--xyzzy` shouldn't match anything above 0.8
let result = correct_args(args("lore --robot issues --xyzzy foo"));
let result = correct_args(args("lore --robot issues --xyzzy foo"), false);
assert!(result.corrections.is_empty());
}
@@ -619,7 +658,7 @@ mod tests {
#[test]
fn flag_eq_value_case_correction() {
let result = correct_args(args("lore --robot issues --State=opened"));
let result = correct_args(args("lore --robot issues --State=opened"), false);
assert_eq!(result.corrections.len(), 1);
assert_eq!(result.corrections[0].corrected, "--state=opened");
}
@@ -628,15 +667,81 @@ mod tests {
#[test]
fn multiple_corrections() {
let result = correct_args(args(
"lore -robot issues --State opened --projct group/repo",
));
let result = correct_args(
args("lore -robot issues --State opened --projct group/repo"),
false,
);
assert_eq!(result.corrections.len(), 3);
assert_eq!(result.args[1], "--robot");
assert_eq!(result.args[3], "--state");
assert_eq!(result.args[5], "--project");
}
// ---- B1: POSIX -- option terminator ----
#[test]
fn option_terminator_stops_corrections() {
let result = correct_args(args("lore issues -- --staate --projct"), false);
// Nothing after `--` should be corrected
assert!(result.corrections.is_empty());
assert_eq!(result.args[2], "--");
assert_eq!(result.args[3], "--staate");
assert_eq!(result.args[4], "--projct");
}
#[test]
fn correction_before_terminator_still_works() {
let result = correct_args(args("lore --Robot issues -- --staate"), false);
assert_eq!(result.corrections.len(), 1);
assert_eq!(result.corrections[0].corrected, "--robot");
assert_eq!(result.args[4], "--staate"); // untouched after --
}
// ---- B2: Clap built-in flags not corrected ----
#[test]
fn version_flag_not_corrected() {
let result = correct_args(args("lore --version"), false);
assert!(result.corrections.is_empty());
assert_eq!(result.args[1], "--version");
}
#[test]
fn help_flag_not_corrected() {
let result = correct_args(args("lore --help"), false);
assert!(result.corrections.is_empty());
assert_eq!(result.args[1], "--help");
}
// ---- I6: Strict mode (robot) disables fuzzy matching ----
#[test]
fn strict_mode_disables_fuzzy() {
// Fuzzy match works in non-strict
let non_strict = correct_args(args("lore --robot issues --staate opened"), false);
assert_eq!(non_strict.corrections.len(), 1);
assert_eq!(non_strict.corrections[0].rule, CorrectionRule::FuzzyFlag);
// Fuzzy match disabled in strict
let strict = correct_args(args("lore --robot issues --staate opened"), true);
assert!(strict.corrections.is_empty());
}
#[test]
fn strict_mode_still_fixes_case() {
let result = correct_args(args("lore --Robot issues --State opened"), true);
assert_eq!(result.corrections.len(), 2);
assert_eq!(result.corrections[0].corrected, "--robot");
assert_eq!(result.corrections[1].corrected, "--state");
}
#[test]
fn strict_mode_still_fixes_single_dash() {
let result = correct_args(args("lore -robot issues"), true);
assert_eq!(result.corrections.len(), 1);
assert_eq!(result.corrections[0].corrected, "--robot");
}
// ---- Teaching notes ----
#[test]

View File

@@ -42,6 +42,8 @@ pub struct IngestResult {
pub notes_upserted: usize,
pub resource_events_fetched: usize,
pub resource_events_failed: usize,
pub mr_diffs_fetched: usize,
pub mr_diffs_failed: usize,
}
#[derive(Debug, Default, Clone, Serialize)]
@@ -606,6 +608,8 @@ async fn run_ingest_inner(
total.mrs_skipped_discussion_sync += result.mrs_skipped_discussion_sync;
total.resource_events_fetched += result.resource_events_fetched;
total.resource_events_failed += result.resource_events_failed;
total.mr_diffs_fetched += result.mr_diffs_fetched;
total.mr_diffs_failed += result.mr_diffs_failed;
}
}
}

View File

@@ -53,8 +53,8 @@ pub struct SearchCliFilters {
pub project: Option<String>,
pub labels: Vec<String>,
pub path: Option<String>,
pub after: Option<String>,
pub updated_after: Option<String>,
pub since: Option<String>,
pub updated_since: Option<String>,
pub limit: usize,
}
@@ -63,22 +63,36 @@ pub fn run_search(
query: &str,
cli_filters: SearchCliFilters,
fts_mode: FtsQueryMode,
requested_mode: &str,
explain: bool,
) -> Result<SearchResponse> {
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
let mut warnings: Vec<String> = Vec::new();
// Determine actual mode: vector search requires embeddings, which need async + Ollama.
// Until hybrid/semantic are wired up, we run lexical and warn if the user asked for more.
let actual_mode = "lexical";
if requested_mode != "lexical" {
warnings.push(format!(
"Requested mode '{}' is not yet available; falling back to lexical search.",
requested_mode
));
}
let doc_count: i64 = conn
.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))
.unwrap_or(0);
if doc_count == 0 {
warnings.push("No documents indexed. Run 'lore generate-docs' first.".to_string());
return Ok(SearchResponse {
query: query.to_string(),
mode: "lexical".to_string(),
mode: actual_mode.to_string(),
total_results: 0,
results: vec![],
warnings: vec!["No documents indexed. Run 'lore generate-docs' first.".to_string()],
warnings,
});
}
@@ -93,25 +107,25 @@ pub fn run_search(
.map(|p| resolve_project(&conn, p))
.transpose()?;
let after = cli_filters
.after
let since = cli_filters
.since
.as_deref()
.map(|s| {
parse_since(s).ok_or_else(|| {
LoreError::Other(format!(
"Invalid --after value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
"Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
s
))
})
})
.transpose()?;
let updated_after = cli_filters
.updated_after
let updated_since = cli_filters
.updated_since
.as_deref()
.map(|s| {
parse_since(s).ok_or_else(|| {
LoreError::Other(format!(
"Invalid --updated-after value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
"Invalid --updated-since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
s
))
})
@@ -130,8 +144,8 @@ pub fn run_search(
source_type,
author: cli_filters.author,
project_id,
after,
updated_after,
since,
updated_since,
labels: cli_filters.labels,
path,
limit: cli_filters.limit,
@@ -163,10 +177,10 @@ pub fn run_search(
if filtered_ids.is_empty() {
return Ok(SearchResponse {
query: query.to_string(),
mode: "lexical".to_string(),
mode: actual_mode.to_string(),
total_results: 0,
results: vec![],
warnings: vec![],
warnings,
});
}
@@ -210,10 +224,10 @@ pub fn run_search(
Ok(SearchResponse {
query: query.to_string(),
mode: "lexical".to_string(),
mode: actual_mode.to_string(),
total_results: results.len(),
results,
warnings: vec![],
warnings,
})
}

View File

@@ -35,6 +35,8 @@ pub struct SyncResult {
pub discussions_fetched: usize,
pub resource_events_fetched: usize,
pub resource_events_failed: usize,
pub mr_diffs_fetched: usize,
pub mr_diffs_failed: usize,
pub documents_regenerated: usize,
pub documents_embedded: usize,
}
@@ -152,6 +154,8 @@ pub async fn run_sync(
result.discussions_fetched += mrs_result.discussions_fetched;
result.resource_events_fetched += mrs_result.resource_events_fetched;
result.resource_events_failed += mrs_result.resource_events_failed;
result.mr_diffs_fetched += mrs_result.mr_diffs_fetched;
result.mr_diffs_failed += mrs_result.mr_diffs_failed;
spinner.finish_and_clear();
if signal.is_cancelled() {
@@ -264,6 +268,8 @@ pub async fn run_sync(
discussions = result.discussions_fetched,
resource_events = result.resource_events_fetched,
resource_events_failed = result.resource_events_failed,
mr_diffs = result.mr_diffs_fetched,
mr_diffs_failed = result.mr_diffs_failed,
docs = result.documents_regenerated,
embedded = result.documents_embedded,
"Sync pipeline complete"
@@ -287,6 +293,12 @@ pub fn print_sync(
" Discussions fetched: {}",
result.discussions_fetched
);
if result.mr_diffs_fetched > 0 || result.mr_diffs_failed > 0 {
println!(" MR diffs fetched: {}", result.mr_diffs_fetched);
if result.mr_diffs_failed > 0 {
println!(" MR diffs failed: {}", result.mr_diffs_failed);
}
}
if result.resource_events_fetched > 0 || result.resource_events_failed > 0 {
println!(
" Resource events fetched: {}",

View File

@@ -10,6 +10,11 @@ use std::io::IsTerminal;
#[command(name = "lore")]
#[command(version = env!("LORE_VERSION"), about = "Local GitLab data management with semantic search", long_about = None)]
#[command(subcommand_required = false)]
#[command(after_long_help = "\x1b[1mEnvironment:\x1b[0m
GITLAB_TOKEN GitLab personal access token (or name set in config)
LORE_ROBOT Enable robot/JSON mode (non-empty, non-zero value)
LORE_CONFIG_PATH Override config file location
NO_COLOR Disable color output (any non-empty value)")]
pub struct Cli {
/// Path to config file
#[arg(short = 'c', long, global = true, help = "Path to config file")]
@@ -541,13 +546,13 @@ pub struct SearchArgs {
#[arg(long, help_heading = "Filters")]
pub path: Option<String>,
/// Filter by created after (7d, 2w, or YYYY-MM-DD)
/// Filter by created since (7d, 2w, or YYYY-MM-DD)
#[arg(long, help_heading = "Filters")]
pub after: Option<String>,
pub since: Option<String>,
/// Filter by updated after (7d, 2w, or YYYY-MM-DD)
#[arg(long = "updated-after", help_heading = "Filters")]
pub updated_after: Option<String>,
/// Filter by updated since (7d, 2w, or YYYY-MM-DD)
#[arg(long = "updated-since", help_heading = "Filters")]
pub updated_since: Option<String>,
/// Maximum results (default 20, max 100)
#[arg(

View File

@@ -14,6 +14,10 @@ pub fn encode_rowid(document_id: i64, chunk_index: i64) -> i64 {
}
pub fn decode_rowid(rowid: i64) -> (i64, i64) {
debug_assert!(
rowid >= 0,
"decode_rowid called with negative rowid: {rowid}"
);
let document_id = rowid / CHUNK_ROWID_MULTIPLIER;
let chunk_index = rowid % CHUNK_ROWID_MULTIPLIER;
(document_id, chunk_index)

View File

@@ -380,7 +380,8 @@ fn reset_discussion_watermarks(conn: &Connection, project_id: i64) -> Result<()>
discussions_sync_attempts = 0,
discussions_sync_last_error = NULL,
resource_events_synced_for_updated_at = NULL,
closes_issues_synced_for_updated_at = NULL
closes_issues_synced_for_updated_at = NULL,
diffs_synced_for_updated_at = NULL
WHERE project_id = ?",
[project_id],
)?;

View File

@@ -25,12 +25,14 @@ pub fn upsert_mr_file_changes(
project_id: i64,
diffs: &[GitLabMrDiff],
) -> Result<usize> {
conn.execute(
let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM mr_file_changes WHERE merge_request_id = ?1",
[mr_local_id],
)?;
let mut stmt = conn.prepare_cached(
let mut stmt = tx.prepare_cached(
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type) \
VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
@@ -54,6 +56,10 @@ pub fn upsert_mr_file_changes(
inserted += 1;
}
// Drop the prepared statement before committing the transaction.
drop(stmt);
tx.commit()?;
if inserted > 0 {
debug!(inserted, mr_local_id, "Stored MR file changes");
}

View File

@@ -516,7 +516,10 @@ pub async fn ingest_project_merge_requests_with_progress(
tracing::Span::current().record("items_processed", result.mrs_upserted);
tracing::Span::current().record("items_skipped", result.mrs_skipped_discussion_sync);
tracing::Span::current().record("errors", result.resource_events_failed);
tracing::Span::current().record(
"errors",
result.resource_events_failed + result.closes_issues_failed + result.mr_diffs_failed,
);
Ok(result)
}
@@ -774,8 +777,9 @@ async fn drain_resource_events(
for p in prefetched {
match p.result {
Ok((state_events, label_events, milestone_events)) => {
let tx = conn.unchecked_transaction()?;
let store_result = store_resource_events(
conn,
&tx,
p.project_id,
&p.entity_type,
p.entity_local_id,
@@ -786,7 +790,6 @@ async fn drain_resource_events(
match store_result {
Ok(()) => {
let tx = conn.unchecked_transaction()?;
complete_job_tx(&tx, p.job_id)?;
update_resource_event_watermark_tx(
&tx,
@@ -797,6 +800,7 @@ async fn drain_resource_events(
result.fetched += 1;
}
Err(e) => {
drop(tx);
warn!(
entity_type = %p.entity_type,
entity_iid = p.entity_iid,
@@ -861,6 +865,7 @@ async fn drain_resource_events(
Ok(result)
}
/// Store resource events using the provided connection (caller manages the transaction).
fn store_resource_events(
conn: &Connection,
project_id: i64,
@@ -870,11 +875,9 @@ fn store_resource_events(
label_events: &[crate::gitlab::types::GitLabLabelEvent],
milestone_events: &[crate::gitlab::types::GitLabMilestoneEvent],
) -> Result<()> {
let tx = conn.unchecked_transaction()?;
if !state_events.is_empty() {
crate::core::events_db::upsert_state_events(
&tx,
conn,
project_id,
entity_type,
entity_local_id,
@@ -884,7 +887,7 @@ fn store_resource_events(
if !label_events.is_empty() {
crate::core::events_db::upsert_label_events(
&tx,
conn,
project_id,
entity_type,
entity_local_id,
@@ -894,7 +897,7 @@ fn store_resource_events(
if !milestone_events.is_empty() {
crate::core::events_db::upsert_milestone_events(
&tx,
conn,
project_id,
entity_type,
entity_local_id,
@@ -902,7 +905,6 @@ fn store_resource_events(
)?;
}
tx.commit()?;
Ok(())
}
@@ -1095,8 +1097,9 @@ async fn drain_mr_closes_issues(
for p in prefetched {
match p.result {
Ok(closes_issues) => {
let tx = conn.unchecked_transaction()?;
let store_result = store_closes_issues_refs(
conn,
&tx,
project_id,
p.entity_local_id,
&closes_issues,
@@ -1104,13 +1107,13 @@ async fn drain_mr_closes_issues(
match store_result {
Ok(()) => {
let tx = conn.unchecked_transaction()?;
complete_job_tx(&tx, p.job_id)?;
update_closes_issues_watermark_tx(&tx, p.entity_local_id)?;
tx.commit()?;
result.fetched += 1;
}
Err(e) => {
drop(tx);
warn!(
entity_iid = p.entity_iid,
error = %e,
@@ -1364,8 +1367,9 @@ async fn drain_mr_diffs(
for p in prefetched {
match p.result {
Ok(diffs) => {
let tx = conn.unchecked_transaction()?;
let store_result = super::mr_diffs::upsert_mr_file_changes(
conn,
&tx,
p.entity_local_id,
project_id,
&diffs,
@@ -1373,13 +1377,13 @@ async fn drain_mr_diffs(
match store_result {
Ok(_) => {
let tx = conn.unchecked_transaction()?;
complete_job_tx(&tx, p.job_id)?;
update_diffs_watermark_tx(&tx, p.entity_local_id)?;
tx.commit()?;
result.fetched += 1;
}
Err(e) => {
drop(tx);
warn!(
entity_iid = p.entity_iid,
error = %e,

View File

@@ -52,7 +52,7 @@ async fn main() {
// Phase 1.5: Pre-clap arg correction for agent typo tolerance
let raw_args: Vec<String> = std::env::args().collect();
let correction_result = autocorrect::correct_args(raw_args);
let correction_result = autocorrect::correct_args(raw_args, robot_mode_early);
// Emit correction warnings to stderr (before clap parsing, so they appear
// even if clap still fails on something else)
@@ -142,6 +142,10 @@ async fn main() {
}
}
// I1: Respect NO_COLOR convention (https://no-color.org/)
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
console::set_colors_enabled(false);
} else {
match cli.color.as_str() {
"never" => console::set_colors_enabled(false),
"always" => console::set_colors_enabled(true),
@@ -150,6 +154,7 @@ async fn main() {
eprintln!("Warning: unknown color mode '{}', using auto", other);
}
}
}
let quiet = cli.quiet;
@@ -451,11 +456,13 @@ fn handle_clap_error(e: clap::Error, robot_mode: bool, corrections: &CorrectionR
if robot_mode {
let error_code = map_clap_error_kind(e.kind());
let message = e
.to_string()
let full_msg = e.to_string();
let message = full_msg
.lines()
.next()
.unwrap_or("Parse error")
.take(3)
.collect::<Vec<_>>()
.join("; ")
.trim()
.to_string();
let (suggestion, correction, valid_values) = match e.kind() {
@@ -684,10 +691,11 @@ fn handle_issues(
print_show_issue(&result);
}
} else {
let state_normalized = args.state.as_deref().map(str::to_lowercase);
let filters = ListFilters {
limit: args.limit,
project: args.project.as_deref(),
state: args.state.as_deref(),
state: state_normalized.as_deref(),
author: args.author.as_deref(),
assignee: args.assignee.as_deref(),
labels: args.label.as_deref(),
@@ -736,10 +744,11 @@ fn handle_mrs(
print_show_mr(&result);
}
} else {
let state_normalized = args.state.as_deref().map(str::to_lowercase);
let filters = MrListFilters {
limit: args.limit,
project: args.project.as_deref(),
state: args.state.as_deref(),
state: state_normalized.as_deref(),
author: args.author.as_deref(),
assignee: args.assignee.as_deref(),
reviewer: args.reviewer.as_deref(),
@@ -1714,13 +1723,20 @@ async fn handle_search(
project: args.project,
labels: args.label,
path: args.path,
after: args.after,
updated_after: args.updated_after,
since: args.since,
updated_since: args.updated_since,
limit: args.limit,
};
let start = std::time::Instant::now();
let response = run_search(&config, &args.query, cli_filters, fts_mode, explain)?;
let response = run_search(
&config,
&args.query,
cli_filters,
fts_mode,
&args.mode,
explain,
)?;
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
@@ -1895,6 +1911,8 @@ struct HealthData {
db_found: bool,
schema_current: bool,
schema_version: i32,
#[serde(skip_serializing_if = "Vec::is_empty")]
actions: Vec<String>,
}
async fn handle_health(
@@ -1929,6 +1947,17 @@ async fn handle_health(
let healthy = config_found && db_found && schema_current;
let mut actions = Vec::new();
if !config_found {
actions.push("lore init".to_string());
}
if !db_found && config_found {
actions.push("lore sync".to_string());
}
if db_found && !schema_current {
actions.push("lore migrate".to_string());
}
if robot_mode {
let output = HealthOutput {
ok: true,
@@ -1938,6 +1967,7 @@ async fn handle_health(
db_found,
schema_current,
schema_version,
actions,
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
@@ -2111,7 +2141,7 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
},
"search": {
"description": "Search indexed documents (lexical, hybrid, semantic)",
"flags": ["<QUERY>", "--mode", "--type", "--author", "-p/--project", "--label", "--path", "--after", "--updated-after", "-n/--limit", "--explain", "--no-explain", "--fts-mode"],
"flags": ["<QUERY>", "--mode", "--type", "--author", "-p/--project", "--label", "--path", "--since", "--updated-since", "-n/--limit", "--explain", "--no-explain", "--fts-mode"],
"example": "lore --robot search 'authentication bug' --mode hybrid --limit 10",
"response_schema": {
"ok": "bool",
@@ -2385,12 +2415,13 @@ async fn handle_list_compat(
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let state_normalized = state_filter.map(str::to_lowercase);
match entity {
"issues" => {
let filters = ListFilters {
limit,
project: project_filter,
state: state_filter,
state: state_normalized.as_deref(),
author: author_filter,
assignee: assignee_filter,
labels: label_filter,
@@ -2418,7 +2449,7 @@ async fn handle_list_compat(
let filters = MrListFilters {
limit,
project: project_filter,
state: state_filter,
state: state_normalized.as_deref(),
author: author_filter,
assignee: assignee_filter,
reviewer: reviewer_filter,

View File

@@ -16,8 +16,8 @@ pub struct SearchFilters {
pub source_type: Option<SourceType>,
pub author: Option<String>,
pub project_id: Option<i64>,
pub after: Option<i64>,
pub updated_after: Option<i64>,
pub since: Option<i64>,
pub updated_since: Option<i64>,
pub labels: Vec<String>,
pub path: Option<PathFilter>,
pub limit: usize,
@@ -28,8 +28,8 @@ impl SearchFilters {
self.source_type.is_some()
|| self.author.is_some()
|| self.project_id.is_some()
|| self.after.is_some()
|| self.updated_after.is_some()
|| self.since.is_some()
|| self.updated_since.is_some()
|| !self.labels.is_empty()
|| self.path.is_some()
}
@@ -85,15 +85,15 @@ pub fn apply_filters(
param_idx += 1;
}
if let Some(after) = filters.after {
if let Some(since) = filters.since {
sql.push_str(&format!(" AND d.created_at >= ?{}", param_idx));
params.push(Box::new(after));
params.push(Box::new(since));
param_idx += 1;
}
if let Some(updated_after) = filters.updated_after {
if let Some(updated_since) = filters.updated_since {
sql.push_str(&format!(" AND d.updated_at >= ?{}", param_idx));
params.push(Box::new(updated_after));
params.push(Box::new(updated_since));
param_idx += 1;
}

View File

@@ -51,8 +51,8 @@ pub fn search_vector(
.collect();
let max_chunks = max_chunks_per_document(conn).max(1);
let multiplier = ((max_chunks.unsigned_abs() as usize * 3 / 2) + 1).max(8);
let k = limit * multiplier;
let multiplier = ((max_chunks.unsigned_abs() as usize * 3 / 2) + 1).clamp(8, 200);
let k = (limit * multiplier).min(10_000);
let mut stmt = conn.prepare(
"SELECT rowid, distance