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/docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Does **not** prove Signal CLI image integrity beyond pinning, or hide network me
| Role | Handlers | Requires |
|------|----------|----------|
| `transcription` | Voice, `!transcribe*`, help, privacy, verify | Whisper sidecar |
| `translation` | Language Threads, `!translate-on`, quote `!translate`, menus, models, verify | `NEAR_AI__API_KEY` |
| `translation` | Language Threads, `!translate-on`, quote `!translate`, menus, verify | `NEAR_AI__API_KEY` |

Fail-fast if role is missing/invalid or required deps are missing.

Expand Down
2 changes: 2 additions & 0 deletions .cursor/rules/commit-messages.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ ci: Harden local Actions mirror.
```

Commits must still pass commitlint (`type: subject`).

Never add Cursor / AI `Co-authored-by` trailers — see [`no-cursor-coauthor.mdc`](no-cursor-coauthor.mdc).
35 changes: 35 additions & 0 deletions .cursor/rules/no-cursor-coauthor.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
description: Never add Cursor or AI co-author trailers to git commits
alwaysApply: true
---

# No Cursor / AI co-authors on commits

Never attribute commits to Cursor, the agent, or any AI model.

## Product setting (source of the problem)

Cursor’s agent harness can **rewrite** `git commit` to inject
`--trailer "Co-authored-by: Cursor <cursoragent@cursor.com>"` even when the
agent did not write that flag. That is unwanted in this repo.

- **IDE Agent:** turn off commit attribution at **Cursor Settings → Agent → Attribution** (commit / co-authored-by).
- **CLI Agent:** set in `~/.cursor/cli-config.json`:
`"attribution": { "attributeCommitsToAgent": false, "attributePRsToAgent": false }`

## Agent behavior

- Do **not** add `Co-authored-by:` trailers for Cursor, Copilot, Claude, GPT, Grok, Composer, or any other AI/tool identity.
- Do **not** set commit author/committer to a Cursor or AI email (`cursoragent@cursor.com`, etc.).
- Commits use the human operator’s normal git identity only.
- Prefer `git commit -m` / HEREDOC with **no** `--trailer`. If the harness still injects a trailer, use plumbing (`git commit-tree`) or strip it before push — never leave Cursor as co-author on a published commit.

```text
# BAD
feat: add menu localization

Co-authored-by: Cursor <cursoragent@cursor.com>

# GOOD
feat: add menu localization
```
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ SIGNAL__POLL_INTERVAL=1s
# NEAR AI Configuration (required when BOT__ROLE=translation)
NEAR_AI__API_KEY=your-api-key-here
NEAR_AI__BASE_URL=https://cloud-api.near.ai/v1
NEAR_AI__MODEL=deepseek-ai/DeepSeek-V3.1
NEAR_AI__MODEL=deepseek-ai/DeepSeek-V4-Flash
NEAR_AI__TIMEOUT=60s

# Bot Configuration
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/commitlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
push:
branches: [main, master]
workflow_dispatch:

jobs:
commitlint:
Expand All @@ -21,6 +22,9 @@ jobs:
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
npx --no -- commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}" --verbose
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
git fetch origin "${{ github.event.repository.default_branch }}" --depth=0
npx --no -- commitlint --from "origin/${{ github.event.repository.default_branch }}" --to "${{ github.sha }}" --verbose
else
npx --no -- commitlint --from "${{ github.event.before }}" --to "${{ github.sha }}" --verbose
fi
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
push:
branches: [main, master]
workflow_dispatch:

jobs:
rust:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ docker compose -f docker/compose.transcription.yaml --env-file docker/transcript
docker compose -f docker/compose.translation.yaml --env-file docker/translation.env up -d
```

More thorough local setup (Signal captcha registration, verify SMS/voice codes, and `docker compose logs -f` monitoring): [docs/local-dev/](docs/local-dev/).

## Phala

```bash
Expand Down
7 changes: 6 additions & 1 deletion crates/near-ai-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@ impl NearAiClient {
// NEAR AI Cloud doesn't expose a /models endpoint
// Return a list of known available models (as of Dec 2025)
Ok(vec![
Model {
id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
object: "model".to_string(),
created: 0,
owned_by: "deepseek".to_string(),
},
Model {
id: "deepseek-ai/DeepSeek-V3.1".to_string(),
object: "model".to_string(),
Expand Down Expand Up @@ -293,7 +299,6 @@ impl NearAiClient {
match self.chat(messages.clone(), temperature, max_tokens).await {
Ok(response) => return Ok(response),
Err(NearAiError::Unauthorized) => return Err(NearAiError::Unauthorized),
Err(NearAiError::EmptyResponse) => return Err(NearAiError::EmptyResponse),
Err(e) => {
warn!("Chat request failed (attempt {}): {}", attempt + 1, e);
last_error = Some(e);
Expand Down
50 changes: 48 additions & 2 deletions crates/near-ai-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ mod tests {

assert!(result.is_ok());
let models = result.unwrap();
assert_eq!(models.len(), 4);
assert_eq!(models[0].id, "deepseek-ai/DeepSeek-V3.1");
assert_eq!(models.len(), 5);
assert_eq!(models[0].id, "deepseek-ai/DeepSeek-V4-Flash");
}

#[tokio::test]
Expand Down Expand Up @@ -186,6 +186,52 @@ mod tests {
assert_eq!(result.unwrap(), "Success on first try");
}

#[tokio::test]
async fn test_chat_with_retry_retries_empty_response() {
let mock_server = MockServer::start().await;

Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "1",
"object": "chat.completion",
"created": 1,
"model": "test-model",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "" },
"finish_reason": "stop"
}]
})))
.up_to_n_times(1)
.expect(1)
.mount(&mock_server)
.await;

Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "2",
"object": "chat.completion",
"created": 1,
"model": "test-model",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Hola" },
"finish_reason": "stop"
}]
})))
.expect(1)
.mount(&mock_server)
.await;

let client = create_test_client(&mock_server).await;
let result = client
.chat_with_retry(vec![Message::user("hi")], None, None, Some(2))
.await;
assert_eq!(result.unwrap(), "Hola");
}

#[tokio::test]
async fn test_message_constructors() {
let system = Message::system("You are a helpful assistant");
Expand Down
39 changes: 30 additions & 9 deletions crates/signal-bot/src/commands/help.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Help command - displays feature menu.

use crate::commands::menu_locale::{help_menu, menu_language_for_message};
use crate::commands::menu_locale::{help_menu, thread_help_menu};
use crate::commands::CommandHandler;
use crate::config::BotRole;
use crate::error::AppResult;
Expand Down Expand Up @@ -31,15 +31,20 @@ impl CommandHandler for HelpHandler {
}

async fn execute(&self, message: &BotMessage) -> AppResult<String> {
let language = menu_language_for_message(message, &self.group_prefs);
Ok(help_menu(language, self.role).into())
if self.role == BotRole::Translation {
if let Some(group_id) = message.group_id.as_deref() {
if self.group_prefs.lookup_sidecar(group_id).is_some() {
return Ok(thread_help_menu().into());
}
}
}
Ok(help_menu(self.role).into())
}
}

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

fn dm(text: &str) -> BotMessage {
BotMessage {
Expand Down Expand Up @@ -81,14 +86,30 @@ mod tests {
assert!(t.contains("!transcription"));
assert!(t.contains("!privacy"));
assert!(!t.contains("Voice notes in this chat"));
assert!(!t.contains("!set-en"));
}

#[tokio::test]
async fn help_in_sidecar_returns_thread_menu() {
let store = GroupPreferencesStore::new_in_memory(0);
store.set_sidecar("main-1", "it", "group.it".into(), "it-internal".into());
let handler = HelpHandler::new(store, BotRole::Translation);
let out = handler
.execute(&group("!help", "it-internal"))
.await
.unwrap();
assert!(out.contains("!rename <name>"));
assert!(out.contains("!translate-me-off"));
assert!(!out.contains("!translation"));
}

#[tokio::test]
async fn help_uses_group_menu_language() {
async fn help_in_main_stays_hub() {
let store = GroupPreferencesStore::new_in_memory(0);
store.set_menu_language("g1", MenuLanguage::Es);
let handler = HelpHandler::new(store, BotRole::Transcription);
let out = handler.execute(&group("!help", "g1")).await.unwrap();
assert!(out.contains("Transcripción de voz"));
store.set_sidecar("main-1", "it", "group.it".into(), "it-internal".into());
let handler = HelpHandler::new(store, BotRole::Translation);
let out = handler.execute(&group("!help", "main-1")).await.unwrap();
assert!(out.contains("!translation"));
assert!(!out.contains("!rename"));
}
}
Loading
Loading