fix(security): resolve all HIGH/MEDIUM/LOW audit findings
Rust fixes (HIGH): - symbols.rs: path validation (reject near-root, 50K file limit, symlink filter) - memory.rs: FTS5 query quoting (prevent operator injection), 1000 fragment cap, content length limit, transaction wrapping - budget.rs: atomic check-and-reserve via transaction, input validation, index on budget_log - export.rs: safe UTF-8 truncation via chars().take() - git_context.rs: reject paths starting with '-' (flag injection) - branch_policy.rs: action validation (block|warn only), path validation Rust fixes (MEDIUM): - export.rs: named column access (positional→named) - budget.rs: named column access, negative value guards Svelte fixes: - AccountSwitcher: 2-step confirmation before account switch - ProjectMemory: expand/collapse content, 2-step delete confirm, tags split fix - CodeIntelligence: min 2-char symbol query, CodeSymbol rename, aria-labels - BudgetManager: 10M upper bound, aria-label on input, named constants - SessionExporter: timeout cleanup on destroy, aria-live feedback - AnalyticsDashboard: SVG aria-label, removed unused import, named constant
This commit is contained in:
parent
0324f813e2
commit
738574b9f0
13 changed files with 280 additions and 91 deletions
|
|
@ -44,17 +44,19 @@ pub fn pro_analytics_summary(project_id: String, days: Option<i64>) -> Result<An
|
||||||
let cutoff = now_epoch() - (period * 86400);
|
let cutoff = now_epoch() - (period * 86400);
|
||||||
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0),
|
"SELECT COUNT(*) AS cnt, COALESCE(SUM(cost_usd), 0) AS total_cost,
|
||||||
COALESCE(SUM(turn_count), 0), COALESCE(SUM(tool_call_count), 0)
|
COALESCE(SUM(peak_tokens), 0) AS total_tokens,
|
||||||
|
COALESCE(SUM(turn_count), 0) AS total_turns,
|
||||||
|
COALESCE(SUM(tool_call_count), 0) AS total_tools
|
||||||
FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2"
|
FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2"
|
||||||
).map_err(|e| format!("Query failed: {e}"))?;
|
).map_err(|e| format!("Query failed: {e}"))?;
|
||||||
|
|
||||||
let result = stmt.query_row(rusqlite::params![project_id, cutoff], |row| {
|
let result = stmt.query_row(rusqlite::params![project_id, cutoff], |row| {
|
||||||
let count: i64 = row.get(0)?;
|
let count: i64 = row.get("cnt")?;
|
||||||
let cost: f64 = row.get(1)?;
|
let cost: f64 = row.get("total_cost")?;
|
||||||
let tokens: i64 = row.get(2)?;
|
let tokens: i64 = row.get("total_tokens")?;
|
||||||
let turns: i64 = row.get(3)?;
|
let turns: i64 = row.get("total_turns")?;
|
||||||
let tools: i64 = row.get(4)?;
|
let tools: i64 = row.get("total_tools")?;
|
||||||
Ok(AnalyticsSummary {
|
Ok(AnalyticsSummary {
|
||||||
total_sessions: count,
|
total_sessions: count,
|
||||||
total_cost_usd: cost,
|
total_cost_usd: cost,
|
||||||
|
|
@ -77,9 +79,11 @@ pub fn pro_analytics_daily(project_id: String, days: Option<i64>) -> Result<Vec<
|
||||||
let cutoff = now_epoch() - (period * 86400);
|
let cutoff = now_epoch() - (period * 86400);
|
||||||
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT date(end_time, 'unixepoch') as day,
|
"SELECT date(end_time, 'unixepoch') AS day,
|
||||||
COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0),
|
COUNT(*) AS cnt, COALESCE(SUM(cost_usd), 0) AS total_cost,
|
||||||
COALESCE(SUM(turn_count), 0), COALESCE(SUM(tool_call_count), 0)
|
COALESCE(SUM(peak_tokens), 0) AS total_tokens,
|
||||||
|
COALESCE(SUM(turn_count), 0) AS total_turns,
|
||||||
|
COALESCE(SUM(tool_call_count), 0) AS total_tools
|
||||||
FROM session_metrics
|
FROM session_metrics
|
||||||
WHERE project_id = ?1 AND end_time >= ?2
|
WHERE project_id = ?1 AND end_time >= ?2
|
||||||
GROUP BY day ORDER BY day ASC"
|
GROUP BY day ORDER BY day ASC"
|
||||||
|
|
@ -87,12 +91,12 @@ pub fn pro_analytics_daily(project_id: String, days: Option<i64>) -> Result<Vec<
|
||||||
|
|
||||||
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
|
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
|
||||||
Ok(DailyStats {
|
Ok(DailyStats {
|
||||||
date: row.get(0)?,
|
date: row.get("day")?,
|
||||||
session_count: row.get(1)?,
|
session_count: row.get("cnt")?,
|
||||||
cost_usd: row.get(2)?,
|
cost_usd: row.get("total_cost")?,
|
||||||
tokens: row.get(3)?,
|
tokens: row.get("total_tokens")?,
|
||||||
turns: row.get(4)?,
|
turns: row.get("total_turns")?,
|
||||||
tool_calls: row.get(5)?,
|
tool_calls: row.get("total_tools")?,
|
||||||
})
|
})
|
||||||
}).map_err(|e| format!("Query failed: {e}"))?
|
}).map_err(|e| format!("Query failed: {e}"))?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
|
@ -108,21 +112,22 @@ pub fn pro_analytics_model_breakdown(project_id: String, days: Option<i64>) -> R
|
||||||
let cutoff = now_epoch() - (period * 86400);
|
let cutoff = now_epoch() - (period * 86400);
|
||||||
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT COALESCE(model, 'unknown'), COUNT(*), COALESCE(SUM(cost_usd), 0),
|
"SELECT COALESCE(model, 'unknown') AS model_name, COUNT(*) AS cnt,
|
||||||
COALESCE(SUM(peak_tokens), 0)
|
COALESCE(SUM(cost_usd), 0) AS total_cost,
|
||||||
|
COALESCE(SUM(peak_tokens), 0) AS total_tokens
|
||||||
FROM session_metrics
|
FROM session_metrics
|
||||||
WHERE project_id = ?1 AND end_time >= ?2
|
WHERE project_id = ?1 AND end_time >= ?2
|
||||||
GROUP BY model ORDER BY SUM(cost_usd) DESC"
|
GROUP BY model ORDER BY SUM(cost_usd) DESC"
|
||||||
).map_err(|e| format!("Query failed: {e}"))?;
|
).map_err(|e| format!("Query failed: {e}"))?;
|
||||||
|
|
||||||
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
|
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
|
||||||
let count: i64 = row.get(1)?;
|
let count: i64 = row.get("cnt")?;
|
||||||
let cost: f64 = row.get(2)?;
|
let cost: f64 = row.get("total_cost")?;
|
||||||
Ok(ModelBreakdown {
|
Ok(ModelBreakdown {
|
||||||
model: row.get(0)?,
|
model: row.get("model_name")?,
|
||||||
session_count: count,
|
session_count: count,
|
||||||
total_cost_usd: cost,
|
total_cost_usd: cost,
|
||||||
total_tokens: row.get(3)?,
|
total_tokens: row.get("total_tokens")?,
|
||||||
avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 },
|
avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 },
|
||||||
})
|
})
|
||||||
}).map_err(|e| format!("Query failed: {e}"))?
|
}).map_err(|e| format!("Query failed: {e}"))?
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ fn ensure_tables(conn: &rusqlite::Connection) -> Result<(), String> {
|
||||||
|
|
||||||
// Seed default policies if table is empty
|
// Seed default policies if table is empty
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM pro_branch_policies", [], |row| row.get(0)
|
"SELECT COUNT(*) AS cnt FROM pro_branch_policies", [], |row| row.get("cnt")
|
||||||
).unwrap_or(0);
|
).unwrap_or(0);
|
||||||
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
|
|
@ -62,6 +62,9 @@ fn glob_match(pattern: &str, value: &str) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_current_branch(project_path: &str) -> Result<String, String> {
|
fn get_current_branch(project_path: &str) -> Result<String, String> {
|
||||||
|
if project_path.starts_with('-') {
|
||||||
|
return Err("Invalid project path: cannot start with '-'".into());
|
||||||
|
}
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.args(["-C", project_path, "branch", "--show-current"])
|
.args(["-C", project_path, "branch", "--show-current"])
|
||||||
.output()
|
.output()
|
||||||
|
|
@ -87,10 +90,10 @@ pub fn pro_branch_check(project_path: String) -> Result<PolicyDecision, String>
|
||||||
|
|
||||||
let policies: Vec<BranchPolicy> = stmt.query_map([], |row| {
|
let policies: Vec<BranchPolicy> = stmt.query_map([], |row| {
|
||||||
Ok(BranchPolicy {
|
Ok(BranchPolicy {
|
||||||
id: row.get(0)?,
|
id: row.get("id")?,
|
||||||
pattern: row.get(1)?,
|
pattern: row.get("pattern")?,
|
||||||
action: row.get(2)?,
|
action: row.get("action")?,
|
||||||
reason: row.get(3)?,
|
reason: row.get("reason")?,
|
||||||
})
|
})
|
||||||
}).map_err(|e| format!("Query failed: {e}"))?
|
}).map_err(|e| format!("Query failed: {e}"))?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
|
@ -127,10 +130,10 @@ pub fn pro_branch_policy_list() -> Result<Vec<BranchPolicy>, String> {
|
||||||
|
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(BranchPolicy {
|
Ok(BranchPolicy {
|
||||||
id: row.get(0)?,
|
id: row.get("id")?,
|
||||||
pattern: row.get(1)?,
|
pattern: row.get("pattern")?,
|
||||||
action: row.get(2)?,
|
action: row.get("action")?,
|
||||||
reason: row.get(3)?,
|
reason: row.get("reason")?,
|
||||||
})
|
})
|
||||||
}).map_err(|e| format!("Query failed: {e}"))?
|
}).map_err(|e| format!("Query failed: {e}"))?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
|
@ -144,6 +147,9 @@ pub fn pro_branch_policy_add(pattern: String, action: Option<String>, reason: Op
|
||||||
let conn = super::open_sessions_db()?;
|
let conn = super::open_sessions_db()?;
|
||||||
ensure_tables(&conn)?;
|
ensure_tables(&conn)?;
|
||||||
let act = action.unwrap_or_else(|| "block".into());
|
let act = action.unwrap_or_else(|| "block".into());
|
||||||
|
if !["block", "warn"].contains(&act.as_str()) {
|
||||||
|
return Err(format!("Invalid action '{}': must be 'block' or 'warn'", act));
|
||||||
|
}
|
||||||
let rsn = reason.unwrap_or_default();
|
let rsn = reason.unwrap_or_default();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO pro_branch_policies (pattern, action, reason) VALUES (?1, ?2, ?3)",
|
"INSERT INTO pro_branch_policies (pattern, action, reason) VALUES (?1, ?2, ?3)",
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ fn ensure_tables(conn: &rusqlite::Connection) -> Result<(), String> {
|
||||||
session_id TEXT NOT NULL,
|
session_id TEXT NOT NULL,
|
||||||
tokens_used INTEGER NOT NULL,
|
tokens_used INTEGER NOT NULL,
|
||||||
timestamp INTEGER NOT NULL
|
timestamp INTEGER NOT NULL
|
||||||
);"
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_budget_log_project ON pro_budget_log(project_id, timestamp);"
|
||||||
).map_err(|e| format!("Failed to create budget tables: {e}"))
|
).map_err(|e| format!("Failed to create budget tables: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,15 +55,59 @@ fn now_epoch() -> i64 {
|
||||||
super::analytics::now_epoch()
|
super::analytics::now_epoch()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate reset date: first day of next month as epoch.
|
/// Calculate reset date: first day of next calendar month as epoch.
|
||||||
fn next_month_epoch() -> i64 {
|
fn next_month_epoch() -> i64 {
|
||||||
let now = now_epoch();
|
let now = now_epoch();
|
||||||
// Approximate: 30 days from now
|
let days_since_epoch = now / 86400;
|
||||||
now + 30 * 86400
|
let mut year = 1970i64;
|
||||||
|
let mut remaining_days = days_since_epoch;
|
||||||
|
loop {
|
||||||
|
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
|
||||||
|
if remaining_days < days_in_year { break; }
|
||||||
|
remaining_days -= days_in_year;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
let month_days: [i64; 12] = if is_leap_year(year) {
|
||||||
|
[31,29,31,30,31,30,31,31,30,31,30,31]
|
||||||
|
} else {
|
||||||
|
[31,28,31,30,31,30,31,31,30,31,30,31]
|
||||||
|
};
|
||||||
|
let mut month = 0usize;
|
||||||
|
for (i, &d) in month_days.iter().enumerate() {
|
||||||
|
if remaining_days < d { month = i; break; }
|
||||||
|
remaining_days -= d;
|
||||||
|
}
|
||||||
|
// Advance to first of next month
|
||||||
|
let (next_year, next_month) = if month >= 11 {
|
||||||
|
(year + 1, 0usize)
|
||||||
|
} else {
|
||||||
|
(year, month + 1)
|
||||||
|
};
|
||||||
|
// Calculate epoch for first of next_month in next_year
|
||||||
|
let mut epoch: i64 = 0;
|
||||||
|
for y in 1970..next_year {
|
||||||
|
epoch += if is_leap_year(y) { 366 } else { 365 };
|
||||||
|
}
|
||||||
|
let nm_days: [i64; 12] = if is_leap_year(next_year) {
|
||||||
|
[31,29,31,30,31,30,31,31,30,31,30,31]
|
||||||
|
} else {
|
||||||
|
[31,28,31,30,31,30,31,31,30,31,30,31]
|
||||||
|
};
|
||||||
|
for i in 0..next_month {
|
||||||
|
epoch += nm_days[i];
|
||||||
|
}
|
||||||
|
epoch * 86400
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_leap_year(y: i64) -> bool {
|
||||||
|
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn pro_budget_set(project_id: String, monthly_limit_tokens: i64) -> Result<(), String> {
|
pub fn pro_budget_set(project_id: String, monthly_limit_tokens: i64) -> Result<(), String> {
|
||||||
|
if monthly_limit_tokens <= 0 {
|
||||||
|
return Err("monthly_limit_tokens must be positive".into());
|
||||||
|
}
|
||||||
let conn = super::open_sessions_db()?;
|
let conn = super::open_sessions_db()?;
|
||||||
ensure_tables(&conn)?;
|
ensure_tables(&conn)?;
|
||||||
let reset = next_month_epoch();
|
let reset = next_month_epoch();
|
||||||
|
|
@ -86,9 +131,9 @@ pub fn pro_budget_get(project_id: String) -> Result<BudgetStatus, String> {
|
||||||
).map_err(|e| format!("Query failed: {e}"))?;
|
).map_err(|e| format!("Query failed: {e}"))?;
|
||||||
|
|
||||||
stmt.query_row(params![project_id], |row| {
|
stmt.query_row(params![project_id], |row| {
|
||||||
let limit: i64 = row.get(0)?;
|
let limit: i64 = row.get("monthly_limit_tokens")?;
|
||||||
let used: i64 = row.get(1)?;
|
let used: i64 = row.get("used_tokens")?;
|
||||||
let reset_date: i64 = row.get(2)?;
|
let reset_date: i64 = row.get("reset_date")?;
|
||||||
let remaining = (limit - used).max(0);
|
let remaining = (limit - used).max(0);
|
||||||
let percent = if limit > 0 { (used as f64 / limit as f64) * 100.0 } else { 0.0 };
|
let percent = if limit > 0 { (used as f64 / limit as f64) * 100.0 } else { 0.0 };
|
||||||
Ok(BudgetStatus { project_id: project_id.clone(), limit, used, remaining, percent, reset_date })
|
Ok(BudgetStatus { project_id: project_id.clone(), limit, used, remaining, percent, reset_date })
|
||||||
|
|
@ -97,6 +142,9 @@ pub fn pro_budget_get(project_id: String) -> Result<BudgetStatus, String> {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn pro_budget_check(project_id: String, estimated_tokens: i64) -> Result<BudgetDecision, String> {
|
pub fn pro_budget_check(project_id: String, estimated_tokens: i64) -> Result<BudgetDecision, String> {
|
||||||
|
if estimated_tokens < 0 {
|
||||||
|
return Err("estimated_tokens must be non-negative".into());
|
||||||
|
}
|
||||||
let conn = super::open_sessions_db()?;
|
let conn = super::open_sessions_db()?;
|
||||||
ensure_tables(&conn)?;
|
ensure_tables(&conn)?;
|
||||||
auto_reset_if_expired(&conn, &project_id)?;
|
auto_reset_if_expired(&conn, &project_id)?;
|
||||||
|
|
@ -105,7 +153,7 @@ pub fn pro_budget_check(project_id: String, estimated_tokens: i64) -> Result<Bud
|
||||||
"SELECT monthly_limit_tokens, used_tokens FROM pro_budgets WHERE project_id = ?1"
|
"SELECT monthly_limit_tokens, used_tokens FROM pro_budgets WHERE project_id = ?1"
|
||||||
).map_err(|e| format!("Query failed: {e}"))?
|
).map_err(|e| format!("Query failed: {e}"))?
|
||||||
.query_row(params![project_id], |row| {
|
.query_row(params![project_id], |row| {
|
||||||
Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
|
Ok((row.get::<_, i64>("monthly_limit_tokens")?, row.get::<_, i64>("used_tokens")?))
|
||||||
});
|
});
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|
@ -133,14 +181,18 @@ pub fn pro_budget_log_usage(project_id: String, session_id: String, tokens_used:
|
||||||
let conn = super::open_sessions_db()?;
|
let conn = super::open_sessions_db()?;
|
||||||
ensure_tables(&conn)?;
|
ensure_tables(&conn)?;
|
||||||
let ts = now_epoch();
|
let ts = now_epoch();
|
||||||
conn.execute(
|
|
||||||
|
let tx = conn.unchecked_transaction()
|
||||||
|
.map_err(|e| format!("Transaction failed: {e}"))?;
|
||||||
|
tx.execute(
|
||||||
"INSERT INTO pro_budget_log (project_id, session_id, tokens_used, timestamp) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT INTO pro_budget_log (project_id, session_id, tokens_used, timestamp) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![project_id, session_id, tokens_used, ts],
|
params![project_id, session_id, tokens_used, ts],
|
||||||
).map_err(|e| format!("Failed to log usage: {e}"))?;
|
).map_err(|e| format!("Failed to log usage: {e}"))?;
|
||||||
conn.execute(
|
tx.execute(
|
||||||
"UPDATE pro_budgets SET used_tokens = used_tokens + ?2 WHERE project_id = ?1",
|
"UPDATE pro_budgets SET used_tokens = used_tokens + ?2 WHERE project_id = ?1",
|
||||||
params![project_id, tokens_used],
|
params![project_id, tokens_used],
|
||||||
).map_err(|e| format!("Failed to update used tokens: {e}"))?;
|
).map_err(|e| format!("Failed to update used tokens: {e}"))?;
|
||||||
|
tx.commit().map_err(|e| format!("Commit failed: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,10 +218,10 @@ pub fn pro_budget_list() -> Result<Vec<BudgetEntry>, String> {
|
||||||
|
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(BudgetEntry {
|
Ok(BudgetEntry {
|
||||||
project_id: row.get(0)?,
|
project_id: row.get("project_id")?,
|
||||||
monthly_limit_tokens: row.get(1)?,
|
monthly_limit_tokens: row.get("monthly_limit_tokens")?,
|
||||||
used_tokens: row.get(2)?,
|
used_tokens: row.get("used_tokens")?,
|
||||||
reset_date: row.get(3)?,
|
reset_date: row.get("reset_date")?,
|
||||||
})
|
})
|
||||||
}).map_err(|e| format!("Query failed: {e}"))?
|
}).map_err(|e| format!("Query failed: {e}"))?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ pub fn pro_export_session(project_id: String, session_id: String) -> Result<Sess
|
||||||
|
|
||||||
let (start, end, tokens, turns, tools, cost, model, status, error): (i64, i64, i64, i64, i64, f64, Option<String>, String, Option<String>) =
|
let (start, end, tokens, turns, tools, cost, model, status, error): (i64, i64, i64, i64, i64, f64, Option<String>, String, Option<String>) =
|
||||||
stmt.query_row(rusqlite::params![project_id, session_id], |row| {
|
stmt.query_row(rusqlite::params![project_id, session_id], |row| {
|
||||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?,
|
Ok((row.get("start_time")?, row.get("end_time")?, row.get("peak_tokens")?, row.get("turn_count")?,
|
||||||
row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?, row.get(8)?))
|
row.get("tool_call_count")?, row.get("cost_usd")?, row.get("model")?, row.get("status")?, row.get("error_message")?))
|
||||||
}).map_err(|e| format!("Session not found: {e}"))?;
|
}).map_err(|e| format!("Session not found: {e}"))?;
|
||||||
|
|
||||||
let model_name = model.clone().unwrap_or_else(|| "unknown".into());
|
let model_name = model.clone().unwrap_or_else(|| "unknown".into());
|
||||||
|
|
@ -55,7 +55,7 @@ pub fn pro_export_session(project_id: String, session_id: String) -> Result<Sess
|
||||||
|
|
||||||
let messages: Vec<(String, String, i64)> = msg_stmt
|
let messages: Vec<(String, String, i64)> = msg_stmt
|
||||||
.query_map(rusqlite::params![session_id], |row| {
|
.query_map(rusqlite::params![session_id], |row| {
|
||||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
Ok((row.get("message_type")?, row.get("content")?, row.get("created_at")?))
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("Messages query failed: {e}"))?
|
.map_err(|e| format!("Messages query failed: {e}"))?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
|
@ -91,9 +91,10 @@ pub fn pro_export_session(project_id: String, session_id: String) -> Result<Sess
|
||||||
"tool_result" => "**Tool Result**",
|
"tool_result" => "**Tool Result**",
|
||||||
_ => msg_type.as_str(),
|
_ => msg_type.as_str(),
|
||||||
};
|
};
|
||||||
// Truncate long content
|
// Truncate long content (safe UTF-8 boundary)
|
||||||
let display = if content.len() > 500 {
|
let display = if content.len() > 500 {
|
||||||
format!("{}... *(truncated, {} chars)*", &content[..500], content.len())
|
let truncated: String = content.chars().take(500).collect();
|
||||||
|
format!("{}... *(truncated, {} chars)*", truncated, content.len())
|
||||||
} else {
|
} else {
|
||||||
content.clone()
|
content.clone()
|
||||||
};
|
};
|
||||||
|
|
@ -126,8 +127,8 @@ pub fn pro_export_project_summary(project_id: String, days: Option<i64>) -> Resu
|
||||||
|
|
||||||
let sessions: Vec<(String, i64, i64, f64, i64, i64, Option<String>, String)> = stmt
|
let sessions: Vec<(String, i64, i64, f64, i64, i64, Option<String>, String)> = stmt
|
||||||
.query_map(rusqlite::params![project_id, cutoff], |row| {
|
.query_map(rusqlite::params![project_id, cutoff], |row| {
|
||||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?,
|
Ok((row.get("session_id")?, row.get("start_time")?, row.get("end_time")?, row.get("cost_usd")?,
|
||||||
row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
|
row.get("turn_count")?, row.get("tool_call_count")?, row.get("model")?, row.get("status")?))
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("Query failed: {e}"))?
|
.map_err(|e| format!("Query failed: {e}"))?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ pub struct BranchInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn git_cmd(project_path: &str, args: &[&str]) -> Result<String, String> {
|
fn git_cmd(project_path: &str, args: &[&str]) -> Result<String, String> {
|
||||||
|
if project_path.starts_with('-') {
|
||||||
|
return Err("Invalid project path: cannot start with '-'".into());
|
||||||
|
}
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.args(["-C", project_path])
|
.args(["-C", project_path])
|
||||||
.args(args)
|
.args(args)
|
||||||
|
|
|
||||||
|
|
@ -64,15 +64,15 @@ fn prune_expired(conn: &rusqlite::Connection) -> Result<(), String> {
|
||||||
|
|
||||||
fn row_to_fragment(row: &rusqlite::Row) -> rusqlite::Result<MemoryFragment> {
|
fn row_to_fragment(row: &rusqlite::Row) -> rusqlite::Result<MemoryFragment> {
|
||||||
Ok(MemoryFragment {
|
Ok(MemoryFragment {
|
||||||
id: row.get(0)?,
|
id: row.get("id")?,
|
||||||
project_id: row.get(1)?,
|
project_id: row.get("project_id")?,
|
||||||
content: row.get(2)?,
|
content: row.get("content")?,
|
||||||
source: row.get(3)?,
|
source: row.get("source")?,
|
||||||
trust: row.get(4)?,
|
trust: row.get("trust")?,
|
||||||
confidence: row.get(5)?,
|
confidence: row.get("confidence")?,
|
||||||
created_at: row.get(6)?,
|
created_at: row.get("created_at")?,
|
||||||
ttl_days: row.get(7)?,
|
ttl_days: row.get("ttl_days")?,
|
||||||
tags: row.get(8)?,
|
tags: row.get("tags")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,8 +83,21 @@ pub fn pro_memory_add(
|
||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
tags: Option<String>,
|
tags: Option<String>,
|
||||||
) -> Result<i64, String> {
|
) -> Result<i64, String> {
|
||||||
|
if content.len() > 10000 {
|
||||||
|
return Err("Memory content too long (max 10000 chars)".into());
|
||||||
|
}
|
||||||
let conn = super::open_sessions_db()?;
|
let conn = super::open_sessions_db()?;
|
||||||
ensure_tables(&conn)?;
|
ensure_tables(&conn)?;
|
||||||
|
|
||||||
|
// Per-project memory cap
|
||||||
|
let count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pro_memories WHERE project_id = ?1",
|
||||||
|
params![project_id], |row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
if count >= 1000 {
|
||||||
|
return Err("Memory limit reached for this project (max 1000 fragments)".into());
|
||||||
|
}
|
||||||
|
|
||||||
let ts = now_epoch();
|
let ts = now_epoch();
|
||||||
let src = source.unwrap_or_default();
|
let src = source.unwrap_or_default();
|
||||||
let tgs = tags.unwrap_or_default();
|
let tgs = tags.unwrap_or_default();
|
||||||
|
|
@ -121,6 +134,9 @@ pub fn pro_memory_search(project_id: String, query: String) -> Result<Vec<Memory
|
||||||
ensure_tables(&conn)?;
|
ensure_tables(&conn)?;
|
||||||
prune_expired(&conn)?;
|
prune_expired(&conn)?;
|
||||||
|
|
||||||
|
// Sanitize query to prevent FTS5 operator injection
|
||||||
|
let safe_query = format!("\"{}\"", query.replace('"', "\"\""));
|
||||||
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT m.id, m.project_id, m.content, m.source, m.trust, m.confidence, m.created_at, m.ttl_days, m.tags
|
"SELECT m.id, m.project_id, m.content, m.source, m.trust, m.confidence, m.created_at, m.ttl_days, m.tags
|
||||||
FROM pro_memories m
|
FROM pro_memories m
|
||||||
|
|
@ -129,7 +145,7 @@ pub fn pro_memory_search(project_id: String, query: String) -> Result<Vec<Memory
|
||||||
ORDER BY rank LIMIT 20"
|
ORDER BY rank LIMIT 20"
|
||||||
).map_err(|e| format!("Search query failed: {e}"))?;
|
).map_err(|e| format!("Search query failed: {e}"))?;
|
||||||
|
|
||||||
let rows = stmt.query_map(params![query, project_id], row_to_fragment)
|
let rows = stmt.query_map(params![safe_query, project_id], row_to_fragment)
|
||||||
.map_err(|e| format!("Search failed: {e}"))?
|
.map_err(|e| format!("Search failed: {e}"))?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
.map_err(|e| format!("Row read failed: {e}"))?;
|
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||||
|
|
@ -147,18 +163,23 @@ pub fn pro_memory_update(
|
||||||
let conn = super::open_sessions_db()?;
|
let conn = super::open_sessions_db()?;
|
||||||
ensure_tables(&conn)?;
|
ensure_tables(&conn)?;
|
||||||
|
|
||||||
|
let tx = conn.unchecked_transaction()
|
||||||
|
.map_err(|e| format!("Transaction failed: {e}"))?;
|
||||||
|
|
||||||
if let Some(c) = content {
|
if let Some(c) = content {
|
||||||
conn.execute("UPDATE pro_memories SET content = ?2 WHERE id = ?1", params![id, c])
|
tx.execute("UPDATE pro_memories SET content = ?2 WHERE id = ?1", params![id, c])
|
||||||
.map_err(|e| format!("Update content failed: {e}"))?;
|
.map_err(|e| format!("Update content failed: {e}"))?;
|
||||||
}
|
}
|
||||||
if let Some(t) = trust {
|
if let Some(t) = trust {
|
||||||
conn.execute("UPDATE pro_memories SET trust = ?2 WHERE id = ?1", params![id, t])
|
tx.execute("UPDATE pro_memories SET trust = ?2 WHERE id = ?1", params![id, t])
|
||||||
.map_err(|e| format!("Update trust failed: {e}"))?;
|
.map_err(|e| format!("Update trust failed: {e}"))?;
|
||||||
}
|
}
|
||||||
if let Some(c) = confidence {
|
if let Some(c) = confidence {
|
||||||
conn.execute("UPDATE pro_memories SET confidence = ?2 WHERE id = ?1", params![id, c])
|
tx.execute("UPDATE pro_memories SET confidence = ?2 WHERE id = ?1", params![id, c])
|
||||||
.map_err(|e| format!("Update confidence failed: {e}"))?;
|
.map_err(|e| format!("Update confidence failed: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tx.commit().map_err(|e| format!("Commit failed: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,7 +206,7 @@ pub fn pro_memory_inject(project_id: String, max_tokens: Option<i64>) -> Result<
|
||||||
).map_err(|e| format!("Query failed: {e}"))?;
|
).map_err(|e| format!("Query failed: {e}"))?;
|
||||||
|
|
||||||
let entries: Vec<(String, String, f64)> = stmt
|
let entries: Vec<(String, String, f64)> = stmt
|
||||||
.query_map(params![project_id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
.query_map(params![project_id], |row| Ok((row.get("content")?, row.get("trust")?, row.get("confidence")?)))
|
||||||
.map_err(|e| format!("Query failed: {e}"))?
|
.map_err(|e| format!("Query failed: {e}"))?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
.map_err(|e| format!("Row read failed: {e}"))?;
|
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||||
|
|
|
||||||
|
|
@ -56,14 +56,32 @@ fn should_skip(name: &str) -> bool {
|
||||||
SKIP_DIRS.contains(&name)
|
SKIP_DIRS.contains(&name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_FILES: usize = 50_000;
|
||||||
|
const MAX_DEPTH: usize = 20;
|
||||||
|
|
||||||
fn walk_files(dir: &Path, files: &mut Vec<PathBuf>) {
|
fn walk_files(dir: &Path, files: &mut Vec<PathBuf>) {
|
||||||
|
walk_files_bounded(dir, files, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_files_bounded(dir: &Path, files: &mut Vec<PathBuf>, depth: usize) {
|
||||||
|
if depth >= MAX_DEPTH || files.len() >= MAX_FILES {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
|
if files.len() >= MAX_FILES {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ft = entry.file_type();
|
||||||
|
// Skip symlinks
|
||||||
|
if ft.as_ref().map_or(false, |ft| ft.is_symlink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_dir() {
|
if ft.as_ref().map_or(false, |ft| ft.is_dir()) {
|
||||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
if !should_skip(name) {
|
if !should_skip(name) {
|
||||||
walk_files(&path, files);
|
walk_files_bounded(&path, files, depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||||
|
|
@ -164,6 +182,9 @@ fn extract_ts_const_fn(line: &str) -> Option<String> {
|
||||||
pub fn pro_symbols_scan(project_path: String) -> Result<ScanResult, String> {
|
pub fn pro_symbols_scan(project_path: String) -> Result<ScanResult, String> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let root = PathBuf::from(&project_path);
|
let root = PathBuf::from(&project_path);
|
||||||
|
if !root.is_absolute() || root.components().count() < 3 {
|
||||||
|
return Err("Invalid project path: must be an absolute path at least 3 levels deep".into());
|
||||||
|
}
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return Err(format!("Not a directory: {project_path}"));
|
return Err(format!("Not a directory: {project_path}"));
|
||||||
}
|
}
|
||||||
|
|
@ -205,6 +226,9 @@ pub fn pro_symbols_search(project_path: String, query: String) -> Result<Vec<Sym
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn pro_symbols_find_callers(project_path: String, symbol_name: String) -> Result<Vec<CallerRef>, String> {
|
pub fn pro_symbols_find_callers(project_path: String, symbol_name: String) -> Result<Vec<CallerRef>, String> {
|
||||||
let root = PathBuf::from(&project_path);
|
let root = PathBuf::from(&project_path);
|
||||||
|
if !root.is_absolute() || root.components().count() < 3 {
|
||||||
|
return Err("Invalid project path: must be an absolute path at least 3 levels deep".into());
|
||||||
|
}
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return Err(format!("Not a directory: {project_path}"));
|
return Err(format!("Not a directory: {project_path}"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let switching = $state<string | null>(null);
|
let switching = $state<string | null>(null);
|
||||||
|
let confirmSwitch = $state<string | null>(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
@ -75,14 +76,31 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="account-action">
|
<div class="account-action">
|
||||||
{#if account.isActive}
|
{#if account.isActive}
|
||||||
<span class="active-label">Active</span>
|
<span class="active-label" aria-label="Currently active account">Active</span>
|
||||||
|
{:else if confirmSwitch === account.id}
|
||||||
|
<button
|
||||||
|
class="switch-btn confirm"
|
||||||
|
disabled={switching !== null}
|
||||||
|
aria-label="Confirm switching to {account.displayName}"
|
||||||
|
onclick={() => { confirmSwitch = null; switchTo(account.id); }}
|
||||||
|
>
|
||||||
|
{switching === account.id ? 'Switching...' : 'Confirm?'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="switch-btn"
|
||||||
|
aria-label="Cancel account switch"
|
||||||
|
onclick={() => (confirmSwitch = null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="switch-btn"
|
class="switch-btn"
|
||||||
disabled={switching !== null}
|
disabled={switching !== null}
|
||||||
onclick={() => switchTo(account.id)}
|
aria-label="Switch to {account.displayName}"
|
||||||
|
onclick={() => (confirmSwitch = account.id)}
|
||||||
>
|
>
|
||||||
{switching === account.id ? 'Switching...' : 'Switch'}
|
Switch
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -199,6 +217,9 @@
|
||||||
|
|
||||||
.account-action {
|
.account-action {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-label {
|
.active-label {
|
||||||
|
|
@ -226,4 +247,14 @@
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-btn.confirm {
|
||||||
|
color: var(--ctp-peach);
|
||||||
|
border-color: var(--ctp-peach);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn.confirm:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--ctp-peach) 12%, transparent);
|
||||||
|
color: var(--ctp-peach);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
// SPDX-License-Identifier: LicenseRef-Commercial
|
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import {
|
import {
|
||||||
proAnalyticsSummary,
|
proAnalyticsSummary,
|
||||||
proAnalyticsDaily,
|
proAnalyticsDaily,
|
||||||
|
|
@ -26,7 +25,9 @@
|
||||||
let daily = $state<DailyStats[]>([]);
|
let daily = $state<DailyStats[]>([]);
|
||||||
let models = $state<ModelBreakdown[]>([]);
|
let models = $state<ModelBreakdown[]>([]);
|
||||||
|
|
||||||
let maxDailyCost = $derived(Math.max(...daily.map((d) => d.costUsd), 0.001));
|
/** Floor value to prevent division-by-zero when all daily costs are 0 */
|
||||||
|
const MIN_CHART_SCALE = 0.001;
|
||||||
|
let maxDailyCost = $derived(Math.max(...daily.map((d) => d.costUsd), MIN_CHART_SCALE));
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
@ -101,7 +102,7 @@
|
||||||
{#if daily.length > 0}
|
{#if daily.length > 0}
|
||||||
<div class="section-title">Daily Cost</div>
|
<div class="section-title">Daily Cost</div>
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<svg viewBox="0 0 {daily.length * 20} 100" class="bar-chart" preserveAspectRatio="none">
|
<svg viewBox="0 0 {daily.length * 20} 100" class="bar-chart" preserveAspectRatio="none" role="img" aria-label="Daily cost bar chart">
|
||||||
{#each daily as d, i}
|
{#each daily as d, i}
|
||||||
{@const h = Math.max((d.costUsd / maxDailyCost) * 90, 1)}
|
{@const h = Math.max((d.costUsd / maxDailyCost) * 90, 1)}
|
||||||
<rect
|
<rect
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@
|
||||||
let routerProfile = $state<string>('balanced');
|
let routerProfile = $state<string>('balanced');
|
||||||
let recommendation = $state<ModelRecommendation | null>(null);
|
let recommendation = $state<ModelRecommendation | null>(null);
|
||||||
|
|
||||||
|
const MAX_TOKEN_LIMIT = 10_000_000;
|
||||||
|
const DEFAULT_RECOMMEND_ROLE = 'default';
|
||||||
|
const DEFAULT_RECOMMEND_PROMPT_LENGTH = 4000;
|
||||||
|
const DEFAULT_RECOMMEND_PROVIDER = 'claude';
|
||||||
|
|
||||||
const PROFILES = [
|
const PROFILES = [
|
||||||
{ id: 'cost_saver', label: 'Cost Saver', desc: 'Smaller models, lower cost', icon: '$' },
|
{ id: 'cost_saver', label: 'Cost Saver', desc: 'Smaller models, lower cost', icon: '$' },
|
||||||
{ id: 'balanced', label: 'Balanced', desc: 'Smart routing by task', icon: '~' },
|
{ id: 'balanced', label: 'Balanced', desc: 'Smart routing by task', icon: '~' },
|
||||||
|
|
@ -49,7 +54,7 @@
|
||||||
budget = b;
|
budget = b;
|
||||||
routerProfile = profile;
|
routerProfile = profile;
|
||||||
limitInput = String(b.limit);
|
limitInput = String(b.limit);
|
||||||
recommendation = await proRouterRecommend(projectId, 'default', 4000, 'claude');
|
recommendation = await proRouterRecommend(projectId, DEFAULT_RECOMMEND_ROLE, DEFAULT_RECOMMEND_PROMPT_LENGTH, DEFAULT_RECOMMEND_PROVIDER);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : String(e);
|
error = e instanceof Error ? e.message : String(e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -64,7 +69,7 @@
|
||||||
|
|
||||||
async function setLimit() {
|
async function setLimit() {
|
||||||
const val = parseInt(limitInput, 10);
|
const val = parseInt(limitInput, 10);
|
||||||
if (isNaN(val) || val <= 0) return;
|
if (isNaN(val) || val <= 0 || val > MAX_TOKEN_LIMIT) return;
|
||||||
settingLimit = true;
|
settingLimit = true;
|
||||||
try {
|
try {
|
||||||
await proBudgetSet(projectId, val);
|
await proBudgetSet(projectId, val);
|
||||||
|
|
@ -80,7 +85,7 @@
|
||||||
try {
|
try {
|
||||||
await proRouterSetProfile(projectId, id);
|
await proRouterSetProfile(projectId, id);
|
||||||
routerProfile = id;
|
routerProfile = id;
|
||||||
recommendation = await proRouterRecommend(projectId, 'default', 4000, 'claude');
|
recommendation = await proRouterRecommend(projectId, DEFAULT_RECOMMEND_ROLE, DEFAULT_RECOMMEND_PROMPT_LENGTH, DEFAULT_RECOMMEND_PROVIDER);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : String(e);
|
error = e instanceof Error ? e.message : String(e);
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +127,8 @@
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={limitInput}
|
bind:value={limitInput}
|
||||||
placeholder="Monthly token limit"
|
placeholder="Monthly token limit"
|
||||||
|
aria-label="Monthly token limit"
|
||||||
|
max={MAX_TOKEN_LIMIT}
|
||||||
/>
|
/>
|
||||||
<button class="btn" onclick={setLimit} disabled={settingLimit}>
|
<button class="btn" onclick={setLimit} disabled={settingLimit}>
|
||||||
{settingLimit ? 'Setting...' : 'Set Limit'}
|
{settingLimit ? 'Setting...' : 'Set Limit'}
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,14 @@
|
||||||
proSymbolsSearch,
|
proSymbolsSearch,
|
||||||
type GitContext,
|
type GitContext,
|
||||||
type PolicyDecision,
|
type PolicyDecision,
|
||||||
type Symbol,
|
type CodeSymbol,
|
||||||
} from './pro-bridge';
|
} from './pro-bridge';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { projectId, projectPath }: Props = $props();
|
let { projectPath }: Props = $props();
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
@ -28,7 +27,7 @@
|
||||||
let scanResult = $state<{ filesScanned: number; symbolsFound: number; durationMs: number } | null>(null);
|
let scanResult = $state<{ filesScanned: number; symbolsFound: number; durationMs: number } | null>(null);
|
||||||
let scanning = $state(false);
|
let scanning = $state(false);
|
||||||
let symbolQuery = $state('');
|
let symbolQuery = $state('');
|
||||||
let symbols = $state<Symbol[]>([]);
|
let symbols = $state<CodeSymbol[]>([]);
|
||||||
let searchingSymbols = $state(false);
|
let searchingSymbols = $state(false);
|
||||||
|
|
||||||
const KIND_COLORS: Record<string, string> = {
|
const KIND_COLORS: Record<string, string> = {
|
||||||
|
|
@ -65,7 +64,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchSymbols() {
|
async function searchSymbols() {
|
||||||
if (!symbolQuery.trim()) { symbols = []; return; }
|
if (!symbolQuery.trim() || symbolQuery.trim().length < 2) { symbols = []; return; }
|
||||||
searchingSymbols = true;
|
searchingSymbols = true;
|
||||||
try { symbols = await proSymbolsSearch(projectPath, symbolQuery.trim()); }
|
try { symbols = await proSymbolsSearch(projectPath, symbolQuery.trim()); }
|
||||||
catch (e) { error = errMsg(e); }
|
catch (e) { error = errMsg(e); }
|
||||||
|
|
@ -148,8 +147,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<input class="search-input" type="text" bind:value={symbolQuery} placeholder="Search symbols..." onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && searchSymbols()} />
|
<input class="search-input" type="text" bind:value={symbolQuery} placeholder="Search symbols..." aria-label="Symbol search query" onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && searchSymbols()} />
|
||||||
<button class="btn" onclick={searchSymbols} disabled={searchingSymbols}>{searchingSymbols ? '...' : 'Find'}</button>
|
<button class="btn" onclick={searchSymbols} disabled={searchingSymbols} aria-label="Find symbols">{searchingSymbols ? '...' : 'Find'}</button>
|
||||||
</div>
|
</div>
|
||||||
{#if symbols.length > 0}
|
{#if symbols.length > 0}
|
||||||
<div class="symbol-list">
|
<div class="symbol-list">
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,17 @@
|
||||||
let addSource = $state<'human' | 'agent' | 'auto'>('human');
|
let addSource = $state<'human' | 'agent' | 'auto'>('human');
|
||||||
let adding = $state(false);
|
let adding = $state(false);
|
||||||
|
|
||||||
|
// Expand/collapse
|
||||||
|
let expandedIds = $state<Set<number>>(new Set());
|
||||||
|
function toggleExpand(id: number) {
|
||||||
|
const next = new Set(expandedIds);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
expandedIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
let confirmDeleteId = $state<number | null>(null);
|
||||||
|
|
||||||
// Inject
|
// Inject
|
||||||
let injectedPreview = $state<string | null>(null);
|
let injectedPreview = $state<string | null>(null);
|
||||||
let injecting = $state(false);
|
let injecting = $state(false);
|
||||||
|
|
@ -106,7 +117,7 @@
|
||||||
<button class="btn secondary" onclick={injectContext} disabled={injecting}>
|
<button class="btn secondary" onclick={injectContext} disabled={injecting}>
|
||||||
{injecting ? 'Injecting...' : 'Inject Context'}
|
{injecting ? 'Injecting...' : 'Inject Context'}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn secondary" disabled>Extract from Session</button>
|
<button class="btn secondary" disabled title="Coming soon">Extract from Session</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -152,17 +163,35 @@
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="trust-badge" style:background={TRUST_COLORS[mem.trust] ?? 'var(--ctp-overlay0)'}>{mem.source}</span>
|
<span class="trust-badge" style:background={TRUST_COLORS[mem.trust] ?? 'var(--ctp-overlay0)'}>{mem.source}</span>
|
||||||
<span class="card-date">{fmtDate(mem.createdAt)}</span>
|
<span class="card-date">{fmtDate(mem.createdAt)}</span>
|
||||||
<button class="del-btn" onclick={() => removeMemory(mem.id)}>x</button>
|
{#if confirmDeleteId === mem.id}
|
||||||
|
<button class="del-btn confirm" onclick={() => { confirmDeleteId = null; removeMemory(mem.id); }}>Confirm?</button>
|
||||||
|
<button class="del-btn" onclick={() => (confirmDeleteId = null)}>Cancel</button>
|
||||||
|
{:else}
|
||||||
|
<button class="del-btn" onclick={() => (confirmDeleteId = mem.id)}>x</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">{mem.content.length > 200 ? mem.content.slice(0, 200) + '...' : mem.content}</div>
|
<div class="card-content">
|
||||||
|
{#if expandedIds.has(mem.id)}
|
||||||
|
{mem.content}
|
||||||
|
{:else if mem.content.length > 200}
|
||||||
|
{mem.content.slice(0, 200)}...
|
||||||
|
{:else}
|
||||||
|
{mem.content}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if mem.content.length > 200}
|
||||||
|
<button class="expand-btn" onclick={() => toggleExpand(mem.id)}>
|
||||||
|
{expandedIds.has(mem.id) ? 'Show less' : 'Show more'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="confidence-bar-bg">
|
<div class="confidence-bar-bg">
|
||||||
<div class="confidence-bar-fill" style:width="{mem.confidence * 100}%"></div>
|
<div class="confidence-bar-fill" style:width="{mem.confidence * 100}%"></div>
|
||||||
</div>
|
</div>
|
||||||
{#if mem.tags}
|
{#if mem.tags}
|
||||||
<div class="card-tags">
|
<div class="card-tags">
|
||||||
{#each mem.tags.split(',') as tag}
|
{#each mem.tags.split(',').filter(Boolean).map(t => t.trim()) as tag}
|
||||||
<span class="tag">{tag.trim()}</span>
|
<span class="tag">{tag}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -234,6 +263,9 @@
|
||||||
.card-date { font-size: 0.6875rem; color: var(--ctp-overlay0); margin-left: auto; }
|
.card-date { font-size: 0.6875rem; color: var(--ctp-overlay0); margin-left: auto; }
|
||||||
.del-btn { background: none; border: none; color: var(--ctp-overlay0); cursor: pointer; font-size: 0.75rem; padding: 0 0.25rem; }
|
.del-btn { background: none; border: none; color: var(--ctp-overlay0); cursor: pointer; font-size: 0.75rem; padding: 0 0.25rem; }
|
||||||
.del-btn:hover { color: var(--ctp-red); }
|
.del-btn:hover { color: var(--ctp-red); }
|
||||||
|
.del-btn.confirm { color: var(--ctp-red); font-weight: 600; }
|
||||||
|
.expand-btn { background: none; border: none; color: var(--ctp-blue); cursor: pointer; font-size: 0.6875rem; padding: 0; align-self: flex-start; }
|
||||||
|
.expand-btn:hover { text-decoration: underline; }
|
||||||
.card-content { font-size: 0.75rem; color: var(--ctp-subtext1); line-height: 1.4; }
|
.card-content { font-size: 0.75rem; color: var(--ctp-subtext1); line-height: 1.4; }
|
||||||
.card-footer { display: flex; flex-direction: column; gap: 0.25rem; }
|
.card-footer { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
.confidence-bar-bg { height: 0.1875rem; background: var(--ctp-surface1); border-radius: 0.125rem; overflow: hidden; }
|
.confidence-bar-bg { height: 0.1875rem; background: var(--ctp-surface1); border-radius: 0.125rem; overflow: hidden; }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// SPDX-License-Identifier: LicenseRef-Commercial
|
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
import {
|
import {
|
||||||
proExportSession,
|
proExportSession,
|
||||||
proExportProjectSummary,
|
proExportProjectSummary,
|
||||||
|
|
@ -24,6 +25,11 @@
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let markdown = $state<string | null>(null);
|
let markdown = $state<string | null>(null);
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearTimeout(copiedTimer);
|
||||||
|
});
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
@ -49,7 +55,8 @@
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(markdown);
|
await navigator.clipboard.writeText(markdown);
|
||||||
copied = true;
|
copied = true;
|
||||||
setTimeout(() => (copied = false), 2000);
|
clearTimeout(copiedTimer);
|
||||||
|
copiedTimer = setTimeout(() => (copied = false), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
error = 'Failed to copy to clipboard';
|
error = 'Failed to copy to clipboard';
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +95,7 @@
|
||||||
{loading ? 'Generating...' : 'Generate Report'}
|
{loading ? 'Generating...' : 'Generate Report'}
|
||||||
</button>
|
</button>
|
||||||
{#if markdown}
|
{#if markdown}
|
||||||
<button class="btn-secondary" onclick={copyToClipboard}>
|
<button class="btn-secondary" onclick={copyToClipboard} aria-live="polite">
|
||||||
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue