Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `vent` MCP tool + `agentflare vent` CLI: agents log tooling friction to an append-only per-repo JSONL; a deterministic classifier consolidates them once per turn (via the PromptSubmit hook) and auto-files actionable vents as backlog items. No new dependencies; fully auditable (raw `vents.jsonl` + `vent list`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the raw-log location.

The implementation stores logs as per-repository files under state_dir()/vents/ (for example, <repo_slug>.jsonl), not a single vents.jsonl. Update this user-facing path description.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 12, Update the changelog entry’s raw-log path
description to state that logs are stored as per-repository files under
state_dir()/vents/, such as <repo_slug>.jsonl, rather than a single vents.jsonl
file.


### Changed

- *(memory)* brain.db now opens through the shared db-kit engine (versioned migrations, WAL, FK enforcement); recall gains optional hybrid semantic search (BM25+vector merge, 30-day temporal decay, MMR) behind `--features semantic`, with `agentflare memory backfill-embeddings` to index existing observations. FTS-only behavior is byte-identical without an embedding model.
Expand Down
1 change: 1 addition & 0 deletions crates/agentflare-backend/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const MIGRATION_LIST: &[M<'static>] = &[
M::up(include_str!("migrations/0003_asset_versioning.sql")),
M::up(include_str!("migrations/0004_item_comments.sql")),
M::up(include_str!("migrations/0005_items_fts.sql")),
M::up(include_str!("migrations/0006_vents.sql")),
];
const MIGRATIONS: Migrations = Migrations::from_slice(MIGRATION_LIST);

Expand Down
1 change: 1 addition & 0 deletions crates/agentflare-backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod item;
pub mod label;
pub mod project;
pub mod state;
pub mod vent;
pub mod webhook;
pub mod workspace;

Expand Down
16 changes: 16 additions & 0 deletions crates/agentflare-backend/src/migrations/0006_vents.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS vents (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
message TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'medium',
tags TEXT NOT NULL DEFAULT '[]',
topic_key TEXT NOT NULL,
seen_count INTEGER NOT NULL DEFAULT 1,
actionable INTEGER NOT NULL DEFAULT 0,
item_id TEXT REFERENCES items(id) ON DELETE SET NULL,
first_event_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(project_id, topic_key)
);
CREATE INDEX IF NOT EXISTS idx_vents_project_actionable ON vents(project_id, actionable);
198 changes: 198 additions & 0 deletions crates/agentflare-backend/src/vent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
use crate::error::Result;
use rusqlite::{Connection, OptionalExtension, params};

#[derive(Debug, Clone, serde::Serialize)]
pub struct Vent {
pub id: String,
pub project_id: String,
pub message: String,
pub severity: String,
pub tags: String,
pub topic_key: String,
pub seen_count: i64,
pub actionable: bool,
pub item_id: Option<String>,
pub first_event_id: String,
pub created_at: i64,
pub updated_at: i64,
}

pub struct UpsertOutcome {
pub id: String,
pub seen_count: i64,
pub existing_item_id: Option<String>,
pub was_actionable: bool,
}

#[allow(clippy::too_many_arguments)]
pub fn upsert(
conn: &Connection,
project_id: &str,
message: &str,
severity: &str,
tags_json: &str,
topic_key: &str,
first_event_id: &str,
seen_delta: i64,
now: i64,
) -> Result<UpsertOutcome> {
let existing = conn
.query_row(
"SELECT id, seen_count, item_id, actionable FROM vents
WHERE project_id = ?1 AND topic_key = ?2",
params![project_id, topic_key],
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, i64>(3)? != 0,
))
},
)
.optional()?;

if let Some((id, seen, item_id, actionable)) = existing {
let new_seen = seen + seen_delta;
conn.execute(
"UPDATE vents SET seen_count = ?2, message = ?3, severity = ?4,
tags = ?5, updated_at = ?6 WHERE id = ?1",
params![id, new_seen, message, severity, tags_json, now],
)?;
return Ok(UpsertOutcome {
id,
seen_count: new_seen,
existing_item_id: item_id,
was_actionable: actionable,
});
}

let id = db_kit::ids::new_id();
conn.execute(
"INSERT INTO vents (id, project_id, message, severity, tags, topic_key,
seen_count, actionable, item_id, first_event_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, NULL, ?8, ?9, ?9)",
params![
id,
project_id,
message,
severity,
tags_json,
topic_key,
seen_delta,
first_event_id,
now
],
)?;
Comment on lines +39 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== vent.rs ==\n'
wc -l crates/agentflare-backend/src/vent.rs
sed -n '1,220p' crates/agentflare-backend/src/vent.rs | cat -n

printf '\n== search vents schema / upsert callers ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
  'CREATE TABLE vents|UNIQUE\\s*\\(project_id,\\s*topic_key\\)|upsert|seen_count|topic_key' \
  crates/agentflare-backend -S

Repository: getappz/agentflare

Length of output: 10102


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== callers of vent::upsert ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' 'vent::upsert|upsert\(' crates/agentflare-backend/src -S

printf '\n== migration 0006_vents.sql ==\n'
cat -n crates/agentflare-backend/src/migrations/0006_vents.sql

Repository: getappz/agentflare

Length of output: 1322


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== transaction / lock / retry usage ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
  'BEGIN IMMEDIATE|BEGIN EXCLUSIVE|transaction\(|immediate_transaction|busy_timeout|retry|ConstraintViolation|UNIQUE\(project_id, topic_key\)' \
  crates/agentflare-backend/src -S

printf '\n== db connection setup ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
  'open_in_memory|open\(|Connection::open|pragma|journal_mode|foreign_keys|busy_timeout' \
  crates/agentflare-backend/src -S

Repository: getappz/agentflare

Length of output: 9315


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== db.rs ==\n'
wc -l crates/agentflare-backend/src/db.rs
sed -n '1,240p' crates/agentflare-backend/src/db.rs | cat -n

printf '\n== connection sharing / mutex / pool ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
  'Mutex<Connection>|Arc<Mutex<Connection>>|Pool<|r2d2|deadpool|Connection>|Db|AppState|State|open_db\(' \
  crates/agentflare-backend/src -S

Repository: getappz/agentflare

Length of output: 10691


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== vent module exports / runtime references ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
  '\bmod vent\b|\bpub use vent\b|\bvent::upsert\b|\bupsert\(' \
  crates/agentflare-backend/src crates -S

printf '\n== where open_db is used outside tests ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
  '\bopen_db\(' \
  . -S

Repository: getappz/agentflare

Length of output: 3298


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== src/vent/consolidate.rs ==\n'
wc -l src/vent/consolidate.rs
sed -n '1,260p' src/vent/consolidate.rs | cat -n

printf '\n== src/cli/vent.rs ==\n'
wc -l src/cli/vent.rs
sed -n '1,180p' src/cli/vent.rs | cat -n

printf '\n== src/hook.rs vent references ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' 'vent::|consolidat|upsert\(' src/hook.rs src/mcp_server.rs -S

Repository: getappz/agentflare

Length of output: 18040


Make the vent upsert atomic
SELECT + separate UPDATE/INSERT can lose seen_count increments under concurrent consolidations, and first-write races can trip the UNIQUE(project_id, topic_key) constraint. Use a single INSERT ... ON CONFLICT(project_id, topic_key) DO UPDATE that increments seen_count in SQL, or wrap the sequence in an immediate transaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-backend/src/vent.rs` around lines 39 - 86, Make the vent
upsert atomic by replacing the existing SELECT/update/insert flow with a single
INSERT ... ON CONFLICT(project_id, topic_key) DO UPDATE statement. Increment
seen_count using the database value plus seen_delta, while preserving the
existing field updates and returning the same UpsertOutcome data, including
prior item_id and actionable state. Use the upsert result to distinguish newly
inserted versus existing vents without allowing concurrent writes to lose
increments or violate the unique constraint.

Ok(UpsertOutcome {
id,
seen_count: seen_delta,
existing_item_id: None,
was_actionable: false,
})
}

pub fn link_item(conn: &Connection, vent_id: &str, item_id: &str) -> Result<()> {
conn.execute(
"UPDATE vents SET item_id = ?2 WHERE id = ?1",
params![vent_id, item_id],
)?;
Ok(())
}

pub fn set_actionable(conn: &Connection, vent_id: &str, actionable: bool) -> Result<()> {
conn.execute(
"UPDATE vents SET actionable = ?2 WHERE id = ?1",
params![vent_id, i64::from(actionable)],
)?;
Ok(())
}

pub fn list(conn: &Connection, project_id: &str, actionable_only: bool) -> Result<Vec<Vent>> {
let mut stmt = conn.prepare(
"SELECT id, project_id, message, severity, tags, topic_key, seen_count,
actionable, item_id, first_event_id, created_at, updated_at
FROM vents WHERE project_id = ?1 AND (?2 = 0 OR actionable = 1)
ORDER BY updated_at DESC",
)?;
let rows = stmt.query_map(params![project_id, i64::from(actionable_only)], |r| {
Ok(Vent {
id: r.get(0)?,
project_id: r.get(1)?,
message: r.get(2)?,
severity: r.get(3)?,
tags: r.get(4)?,
topic_key: r.get(5)?,
seen_count: r.get(6)?,
actionable: r.get::<_, i64>(7)? != 0,
item_id: r.get(8)?,
first_event_id: r.get(9)?,
created_at: r.get(10)?,
updated_at: r.get(11)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::db::open_in_memory;

fn seed_project(conn: &rusqlite::Connection) -> String {
conn.execute("INSERT INTO workspaces (id,name,slug,item_label,created_at,updated_at) VALUES ('w','W','w','Item',1,1)", []).unwrap();
conn.execute("INSERT INTO projects (id,workspace_id,name,identifier,created_at,updated_at) VALUES ('p','w','P','P',1,1)", []).unwrap();
"p".to_string()
}

#[test]
fn upsert_dedups_by_topic_and_accumulates_seen_count() {
let conn = open_in_memory().unwrap();
let p = seed_project(&conn);
let a = upsert(
&conn,
&p,
"disk full",
"medium",
"[]",
"disk full",
"ev1",
1,
100,
)
.unwrap();
assert_eq!(a.seen_count, 1);
assert!(a.existing_item_id.is_none());
let b = upsert(
&conn,
&p,
"disk full",
"high",
"[]",
"disk full",
"ev2",
5,
200,
)
.unwrap();
assert_eq!(a.id, b.id, "same topic → same row");
assert_eq!(b.seen_count, 6, "1 + delta 5");
assert_eq!(list(&conn, &p, false).unwrap().len(), 1);
}

#[test]
fn link_item_and_actionable_filter() {
let conn = open_in_memory().unwrap();
let p = seed_project(&conn);
conn.execute("INSERT INTO states (id,project_id,name,group_name,sequence,is_default,created_at,updated_at) VALUES ('s','p','Backlog','backlog',1.0,1,1,1)", []).unwrap();
conn.execute("INSERT INTO items (id,project_id,state_id,name,created_at,updated_at) VALUES ('it','p','s','x',1,1)", []).unwrap();
let v = upsert(&conn, &p, "m", "low", "[]", "m", "ev", 1, 1).unwrap();
set_actionable(&conn, &v.id, true).unwrap();
link_item(&conn, &v.id, "it").unwrap();
let only = list(&conn, &p, true).unwrap();
assert_eq!(only.len(), 1);
assert_eq!(only[0].item_id.as_deref(), Some("it"));
assert!(only[0].actionable);
}
}
3 changes: 3 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod run;
mod serve;
mod uninstall;
mod update;
mod vent;

use clap::{Parser, Subcommand};
use std::sync::LazyLock;
Expand Down Expand Up @@ -67,6 +68,7 @@ pub enum Commands {
Review(review::ReviewArgs),
Memory(memory::MemoryArgs),
Serve(serve::ServeArgs),
Vent(vent::VentArgs),
}

impl Commands {
Expand Down Expand Up @@ -95,6 +97,7 @@ impl Commands {
Self::Review(cmd) => cmd.run(),
Self::Memory(cmd) => cmd.run(),
Self::Serve(cmd) => cmd.run(),
Self::Vent(cmd) => vent::run(cmd),
}
}
}
117 changes: 117 additions & 0 deletions src/cli/vent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
use clap::{Args, Subcommand};

#[derive(Args)]
pub struct VentArgs {
#[command(subcommand)]
pub cmd: VentCmd,
}

#[derive(Subcommand)]
pub enum VentCmd {
/// Log a friction vent (append-only; triaged at the next turn).
Say {
message: String,
#[arg(long, default_value = "medium")]
severity: String,
#[arg(long = "tag")]
tags: Vec<String>,
},
/// Triage buffered vents now (also run automatically once per turn).
Consolidate,
/// List triaged vents for this repo's project.
List {
#[arg(long)]
actionable: bool,
},
}

pub fn run(args: VentArgs) {
match args.cmd {
VentCmd::Say {
message,
severity,
tags,
} => {
let severity = crate::vent::classify::normalize_severity(Some(&severity));
let log = crate::vent::paths::log_path();
match crate::vent::capture::append(&log, None, severity, &tags, message.trim()) {
Ok(id) => println!("vented {id}"),
Err(e) => eprintln!("vent failed: {e}"),
}
}
VentCmd::Consolidate => {
let r = crate::vent::consolidate::consolidate();
println!(
"consolidated {} vent(s) → {} item(s){}",
r.consolidated,
r.items_created.len(),
if r.buffered_no_project > 0 {
format!(
" ({} buffered — no linked project yet)",
r.buffered_no_project
)
} else {
String::new()
}
);
for id in r.items_created {
println!(" filed item {id}");
}
}
VentCmd::List { actionable } => {
let conn = match agentflare_backend::db::open_db(&crate::vent::paths::backend_db_path())
{
Ok(c) => c,
Err(e) => {
eprintln!("cannot open backend: {e}");
return;
}
};
let link = crate::vent::paths::repo_root()
.join(".agentflare")
.join("project.json");
let project_id = std::fs::read(&link)
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| {
v.get("project_id")
.and_then(|p| p.as_str().map(String::from))
});
let Some(pid) = project_id else {
println!("no linked project — run an agentflare item/memory command here first");
return;
};
match agentflare_backend::vent::list(&conn, &pid, actionable) {
Ok(vents) => {
for v in vents {
println!(
"{} seen×{} {} {}{}",
if v.actionable { "●" } else { "○" },
v.seen_count,
v.severity,
v.message.split('\n').next().unwrap_or(""),
v.item_id
.map(|i| format!(" → item {i}"))
.unwrap_or_default(),
);
}
}
Err(e) => eprintln!("list failed: {e}"),
}
}
}
}

#[cfg(test)]
mod tests {
#[test]
fn append_then_read_back_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("v.jsonl");
crate::vent::capture::append(&log, None, "high", &[], "roundtrip check").unwrap();
let (lines, _) =
crate::vent::consolidate::read_new_lines(&log, &dir.path().join("v.cursor"));
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].message, "roundtrip check");
}
}
Loading
Loading