Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ src/
│ ├── react.rs — add emoji reaction (channel only)
│ ├── memory_save.rs — write memory to store (branch + cortex + compactor)
│ ├── memory_recall.rs— search + curate memories (branch only)
│ ├── channel_recall.rs— retrieve transcript from other channels (branch only)
│ ├── channel_recall.rs— retrieve transcript from any channel (branch only)
│ ├── set_status.rs — update worker status (workers only)
│ ├── shell.rs — execute shell commands (task workers)
│ ├── file.rs — read/write/list files (task workers)
Expand Down
6 changes: 4 additions & 2 deletions prompts/en/channel.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ You are able to write code or do work extremely fast inside a worker, never say

You have three paths for getting things done. Choosing the right one matters.

**Branch** — for thinking and memory. Branch when you need to recall, save, or forget something from long-term memory, manage the task board (create, list, update, or approve tasks), reason through a complex decision, figure out what instructions to give a worker, answer Spacebot self-knowledge questions (features, architecture, configuration, release notes), or retrieve transcript context from another channel. Branches have your full conversation context and access to the memory system (recall, save, and delete), Spacebot docs lookup (`spacebot_docs`), task tools (`task_create`, `task_list`, `task_update`), cross-channel transcript recall (`channel_recall`), and worker transcript inspection (`worker_inspect`). They return a conclusion. You never see the working. Branch often — it's cheap and keeps you responsive.
**Branch** — for thinking and memory. Branch when you need to recall, save, or forget something from long-term memory, manage the task board (create, list, update, or approve tasks), reason through a complex decision, figure out what instructions to give a worker, answer Spacebot self-knowledge questions (features, architecture, configuration, release notes), or retrieve transcript context from another channel. Branches have your full conversation context and access to the memory system (recall, save, and delete), Spacebot docs lookup (`spacebot_docs`), task tools (`task_create`, `task_list`, `task_update`), cross-channel transcript recall (`channel_recall` — queries the full persisted message database, supports temporal filtering), and worker transcript inspection (`worker_inspect`). They return a conclusion. You never see the working. Branch often — it's cheap and keeps you responsive.

**Worker** — for doing. Workers have execution tools (see Worker Capabilities section below). They do NOT have your conversation context or access to memories — they only know what you tell them in the task description, so be specific. Two flavors:

Expand All @@ -60,6 +60,8 @@ Use `worker_inspect` in a branch when you need to verify what a worker actually

The key distinction: branches think, workers do, you talk. Never use a worker for memory recall. Never search memories yourself — branch first. Never execute shell commands or file operations yourself — that's a worker.

Never suggest that the user do something you could do yourself. If someone asks you to recall, search, look something up, run a command, or find information — do it using your tools. Don't tell them to "check the database manually", "scroll through history", or "ask someone else". If your tools can handle it, use them. If they can't, say so plainly — but try first.

## Builtin Worker Sandbox

{%- if sandbox_enabled %}
Expand Down Expand Up @@ -107,7 +109,7 @@ When in doubt, skip. Being a lurker who speaks when it matters is better than be

1. Always use the tool call API for actions. Your text output is sent verbatim to users — never write tool call syntax (like `[reply]`, `[react]`, `[skip]`, etc.) as plain text. If you want to reply, call the `reply` tool. If you want to react, call the `react` tool.
2. Never execute tasks directly. If it needs shell commands, file operations, web browsing, or web search — that's a worker.
3. Never search memories yourself. Branch to recall. If you need conversation context from another channel, branch and use `channel_recall`.
3. Never search memories yourself. Branch to recall. If you need conversation history from any channel (including this one), branch and use `channel_recall` — it queries the full persisted database and supports temporal filtering.
4. When you spawn a worker, always reply with a brief natural acknowledgment so the user knows you're on it — something like "On it", "Checking now", "Let me look into that", or a relevant follow-up question. When you branch (for memory or thinking), prefer `skip` — branches are fast and invisible. Never mention internal process details (branch, worker, status block).
5. Keep responses conversational. You're talking to a person, not filing a report.
6. If multiple things are happening, handle them in a natural flow. No rigid ordering.
Expand Down
2 changes: 1 addition & 1 deletion prompts/en/tools/channel_recall_description.md.j2
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Recall conversation transcript from another channel. Use without a channel argument to list all available channels. Use with a channel name or ID to retrieve recent messages from that channel's conversation history.
Recall conversation transcript from any channel, including the current one. Use without a channel argument to list all available channels. Use with a channel name or ID to retrieve messages from that channel's conversation history. Supports temporal filtering with `before` and `after` (RFC 3339 timestamps) to query specific time ranges, and `oldest_first` to retrieve the earliest messages instead of the most recent. For example, to find the first messages ever sent in a channel, use `oldest_first: true` with a small limit. This queries the full persisted message history in the database, not just the current in-memory context window.
55 changes: 43 additions & 12 deletions src/conversation/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,24 +172,52 @@ impl ConversationLogger {
Ok(messages)
}

/// Load recent messages from any channel (not just the current one).
/// Load messages from any channel (not just the current one).
///
/// Supports optional temporal filtering via `before` and `after` (RFC 3339 strings)
/// and ordering via `oldest_first`. When `oldest_first` is true, returns the earliest
/// matching messages instead of the most recent.
pub async fn load_channel_transcript(
&self,
channel_id: &str,
limit: i64,
before: Option<&str>,
after: Option<&str>,
oldest_first: bool,
) -> crate::error::Result<Vec<ConversationMessage>> {
let rows = sqlx::query(
let mut sql = String::from(
"SELECT id, channel_id, role, sender_name, sender_id, content, metadata, created_at \
FROM conversation_messages \
WHERE channel_id = ? \
ORDER BY created_at DESC \
LIMIT ?",
)
.bind(channel_id)
.bind(limit)
.fetch_all(&self.pool)
.await
.map_err(|e| anyhow::anyhow!(e))?;
WHERE channel_id = ?",
);

if before.is_some() {
sql.push_str(" AND created_at < ?");
}
if after.is_some() {
sql.push_str(" AND created_at > ?");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor correctness gotcha: created_at is coming from SQLite CURRENT_TIMESTAMP (YYYY-MM-DD HH:MM:SS), so comparing it directly to RFC3339 input via < ?/> ? can behave oddly (esp. same-day comparisons because of the space vs T). Using datetime(?) keeps RFC3339 working without changing storage format.

Suggested change
}
if before.is_some() {
sql.push_str(" AND created_at < datetime(?)");
}
if after.is_some() {
sql.push_str(" AND created_at > datetime(?)");
}

Comment on lines +194 to +199

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and get line count
wc -l src/conversation/history.rs

Repository: spacedriveapp/spacebot

Length of output: 97


🏁 Script executed:

# Read the relevant section (lines 180-220 for context)
sed -n '180,220p' src/conversation/history.rs

Repository: spacedriveapp/spacebot

Length of output: 1309


🏁 Script executed:

# Check the function signature and parameter documentation
sed -n '170,200p' src/conversation/history.rs

Repository: spacedriveapp/spacebot

Length of output: 1081


🏁 Script executed:

# Look for the referenced timeline query at lines 413-415
sed -n '410,420p' src/conversation/history.rs

Repository: spacedriveapp/spacebot

Length of output: 377


🏁 Script executed:

# Search for how before/after are documented or typed
rg -n "before|after" src/conversation/history.rs | head -20

Repository: spacedriveapp/spacebot

Length of output: 810


🏁 Script executed:

# Run the verification script to demonstrate SQLite comparison behavior
python3 << 'PY'
import sqlite3

conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.execute("CREATE TABLE t(created_at TEXT)")
cur.executemany("INSERT INTO t(created_at) VALUES (?)", [
    ("2026-01-15 00:30:00",),
    ("2026-01-15 23:59:59",),
])

bound = "2026-01-15T00:00:00Z"

print(f"Testing comparison with bound value: {bound}")
print()

plain = cur.execute(
    "SELECT created_at FROM t WHERE created_at < ? ORDER BY created_at",
    (bound,)
).fetchall()

normalized = cur.execute(
    "SELECT created_at FROM t WHERE datetime(created_at) < datetime(?) ORDER BY created_at",
    (bound,)
).fetchall()

print("Plain text compare (created_at < ?):", plain)
print("datetime() compare (datetime(created_at) < datetime(?)):", normalized)
print()
print("Analysis:")
print(f"  'T' (ASCII {ord('T')}) vs ' ' (ASCII {ord(' ')}) in string comparison")
print(f"  2026-01-15T00:00:00Z > 2026-01-15 (as strings): {bound > '2026-01-15'}")
PY

Repository: spacedriveapp/spacebot

Length of output: 396


🏁 Script executed:

# Verify the table schema to confirm created_at is stored as TEXT
rg -n "conversation_messages|CREATE TABLE" src/conversation/history.rs | head -10

Repository: spacedriveapp/spacebot

Length of output: 553


🏁 Script executed:

# Check if there are other places where before/after are used to see the broader pattern
rg -n "load_channel_transcript|RFC 3339" src/conversation/history.rs

Repository: spacedriveapp/spacebot

Length of output: 204


Use SQLite datetime normalization in temporal filters.

Lines 194-195 and 198 compare created_at to raw RFC 3339 string parameters without datetime normalization. String comparison of "2026-01-15T00:00:00Z" (where 'T' = ASCII 84) with "2026-01-15 HH:MM:SS" (where ' ' = ASCII 32) produces inverted filter results: rows are included when they should be excluded.

🛠️ Proposed fix
         if before.is_some() {
-            sql.push_str(" AND created_at < ?");
+            sql.push_str(" AND datetime(created_at) < datetime(?)");
         }
         if after.is_some() {
-            sql.push_str(" AND created_at > ?");
+            sql.push_str(" AND datetime(created_at) > datetime(?)");
         }

A reference implementation with the correct pattern already exists in the same file (lines 413–415).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if before.is_some() {
sql.push_str(" AND created_at < ?");
}
if after.is_some() {
sql.push_str(" AND created_at > ?");
}
if before.is_some() {
sql.push_str(" AND datetime(created_at) < datetime(?)");
}
if after.is_some() {
sql.push_str(" AND datetime(created_at) > datetime(?)");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/conversation/history.rs` around lines 194 - 199, The temporal filters use
raw string comparison (" AND created_at < ?" / " AND created_at > ?") which
misorders RFC3339 vs space-separated datetimes; update the sql.append calls to
normalize datetimes using SQLite's datetime() like the existing pattern at lines
~413–415: replace the two sql.push_str invocations that append " AND created_at
< ?" and " AND created_at > ?" with " AND datetime(created_at) < datetime(?)"
and " AND datetime(created_at) > datetime(?)" respectively so created_at and the
bound parameter are compared as normalized datetimes (keep the same bound
parameter variables before/after).


if oldest_first {
sql.push_str(" ORDER BY created_at ASC");
} else {
sql.push_str(" ORDER BY created_at DESC");
}
sql.push_str(" LIMIT ?");

let mut query = sqlx::query(&sql).bind(channel_id);
if let Some(before) = before {
query = query.bind(before);
}
if let Some(after) = after {
query = query.bind(after);
}
query = query.bind(limit);

let rows = query
.fetch_all(&self.pool)
.await
.map_err(|e| anyhow::anyhow!(e))?;

let mut messages: Vec<ConversationMessage> = rows
.into_iter()
Expand All @@ -207,7 +235,10 @@ impl ConversationLogger {
})
.collect();

messages.reverse();
// When fetching newest-first, reverse to chronological for the caller
if !oldest_first {
messages.reverse();
}
Ok(messages)
}
}
Expand Down
35 changes: 32 additions & 3 deletions src/tools/channel_recall.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Cross-channel transcript recall tool for branches.
//! Channel transcript recall tool for branches. Queries any channel including the current one.

use crate::conversation::channels::ChannelStore;
use crate::conversation::history::ConversationLogger;
Expand All @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
/// Maximum messages to return in a single recall.
const MAX_TRANSCRIPT_MESSAGES: i64 = 100;

/// Tool for recalling conversation transcript from other channels.
/// Tool for recalling conversation transcript from any channel.
#[derive(Debug, Clone)]
pub struct ChannelRecallTool {
conversation_logger: ConversationLogger,
Expand Down Expand Up @@ -43,6 +43,16 @@ pub struct ChannelRecallArgs {
/// Maximum number of messages to return (default 50, max 100).
#[serde(default = "default_message_limit")]
pub limit: i64,
/// Only return messages sent before this timestamp (RFC 3339, e.g. "2026-01-15T00:00:00Z").
#[serde(default)]
pub before: Option<String>,
/// Only return messages sent after this timestamp (RFC 3339, e.g. "2026-01-15T00:00:00Z").
#[serde(default)]
pub after: Option<String>,
/// When true, returns the oldest matching messages first instead of the most recent.
/// Useful for finding the earliest messages in a channel.
#[serde(default)]
pub oldest_first: bool,
}

fn default_message_limit() -> i64 {
Expand Down Expand Up @@ -107,6 +117,19 @@ impl Tool for ChannelRecallTool {
"maximum": 100,
"default": 50,
"description": "Maximum number of messages to retrieve (1-100)"
},
"before": {
"type": "string",
"description": "Only return messages before this timestamp (RFC 3339, e.g. \"2026-01-15T00:00:00Z\")"
},
"after": {
"type": "string",
"description": "Only return messages after this timestamp (RFC 3339, e.g. \"2026-01-15T00:00:00Z\")"
},
"oldest_first": {
"type": "boolean",
"default": false,
"description": "Return oldest messages first instead of most recent. Use this to find the earliest messages in a channel."
}
}
}),
Expand Down Expand Up @@ -139,7 +162,13 @@ impl Tool for ChannelRecallTool {
// Load transcript
let messages = self
.conversation_logger
.load_channel_transcript(&channel.id, limit)
.load_channel_transcript(
&channel.id,
limit,
args.before.as_deref(),
args.after.as_deref(),
args.oldest_first,
)
Comment on lines 162 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Might be worth validating before/after up-front. As-is, an invalid RFC3339 string will just turn into "no matches" at the SQL layer, which is hard to debug from the agent side.

Suggested change
// Load transcript
let messages = self
.conversation_logger
.load_channel_transcript(&channel.id, limit)
.load_channel_transcript(
&channel.id,
limit,
args.before.as_deref(),
args.after.as_deref(),
args.oldest_first,
)
if let Some(before) = args.before.as_deref() {
chrono::DateTime::parse_from_rfc3339(before)
.map_err(|e| ChannelRecallError(format!("Invalid `before` timestamp: {e}")))?;
}
if let Some(after) = args.after.as_deref() {
chrono::DateTime::parse_from_rfc3339(after)
.map_err(|e| ChannelRecallError(format!("Invalid `after` timestamp: {e}")))?;
}
// Load transcript
let messages = self
.conversation_logger
.load_channel_transcript(
&channel.id,
limit,
args.before.as_deref(),
args.after.as_deref(),
args.oldest_first,
)

.await
.map_err(|e| ChannelRecallError(format!("Failed to load transcript: {e}")))?;

Expand Down