Compare commits

...

20 Commits

Author SHA1 Message Date
Timmy 0995c55a82 Bump version to 0.8.8 2026-04-03 11:07:39 +01:00
dave 41197c667a storkit: done 460_bug_strip_bot_mention_fails_on_element_markdown_mention_pill_format 2026-04-03 10:00:54 +00:00
dave 7da73aa435 storkit: merge 460_bug_strip_bot_mention_fails_on_element_markdown_mention_pill_format 2026-04-03 10:00:50 +00:00
dave 3d83cc61b6 storkit: create 461_bug_strip_bot_mention_fails_on_element_markdown_mention_pill_format 2026-04-03 09:53:38 +00:00
dave 334d52bd2b storkit: create 460_bug_strip_bot_mention_fails_on_element_markdown_mention_pill_format 2026-04-03 09:51:18 +00:00
dave 8ff1de73d4 storkit: accept 458_story_matrix_bot_ignores_messages_addressed_to_other_bots_in_ambient_mode 2026-04-02 21:06:38 +00:00
dave d37fdf8e10 fix: strip emoji between bot mention and command text
strip_mention_separator now skips all non-ASCII-alphanumeric chars
(emoji, colons, spaces) and returns a slice starting at the first
command character. Fixes mention pills with emoji display names
(e.g. "timmy ️ status") not matching bot commands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:06:52 +00:00
dave 7ff88641c0 storkit: done 459_bug_matrix_history_json_and_timers_json_missing_from_scaffold_storkit_gitignore 2026-04-02 17:18:31 +00:00
dave b8ac5622d6 storkit: merge 459_bug_matrix_history_json_and_timers_json_missing_from_scaffold_storkit_gitignore 2026-04-02 17:18:28 +00:00
dave 4df3f8594c storkit: accept 457_bug_store_json_created_at_project_root_instead_of_inside_storkit 2026-04-02 17:15:50 +00:00
dave 56e71293d6 chore: remove debug log from verification handler 2026-04-02 17:10:09 +00:00
dave 2df214cad1 storkit: create 459_bug_matrix_history_json_and_timers_json_missing_from_scaffold_storkit_gitignore 2026-04-02 17:02:54 +00:00
dave f43b84a7ef storkit: done 458_story_matrix_bot_ignores_messages_addressed_to_other_bots_in_ambient_mode 2026-04-02 15:51:25 +00:00
dave ce4a0cb7f9 storkit: merge 458_story_matrix_bot_ignores_messages_addressed_to_other_bots_in_ambient_mode 2026-04-02 15:51:22 +00:00
dave 52e9fe2a87 storkit: accept 456_bug_matrix_bot_ignores_in_room_verification_requests_from_element 2026-04-02 15:41:28 +00:00
dave a22d67c36c storkit: create 458_story_matrix_bot_ignores_messages_addressed_to_other_bots_in_ambient_mode 2026-04-02 15:37:30 +00:00
dave 0cb98c2a3e storkit: accept 454_story_deduplicate_work_item_display_in_web_ui_story_panel 2026-04-02 15:17:41 +00:00
dave e6439238d2 storkit: done 457_bug_store_json_created_at_project_root_instead_of_inside_storkit 2026-04-02 13:27:49 +00:00
dave 967a306ea8 storkit: merge 457_bug_store_json_created_at_project_root_instead_of_inside_storkit 2026-04-02 13:27:46 +00:00
dave 46d09d4d45 storkit: create 457_bug_store_json_created_at_project_root_instead_of_inside_storkit 2026-04-02 13:15:04 +00:00
19 changed files with 432 additions and 28 deletions
@@ -0,0 +1,28 @@
---
name: "strip_bot_mention fails on Element markdown mention pill format"
---
# Bug 461: strip_bot_mention fails on Element markdown mention pill format
## Description
When Element sends a message with a mention pill, the plain text body uses Markdown link format: `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status`. The `strip_bot_mention` function in chat/util.rs uses `strip_prefix_ci` which expects the message to start with `@timmy` or the display name. Since the message starts with `[`, all prefix checks fail, the mention is not stripped, and the entire Markdown link becomes the "command name". Deterministic commands like `status`, `help`, etc. are never matched — they fall through to the LLM instead. The `mentions_bot` function works correctly because it uses `contains()` rather than prefix matching, so the bot IS triggered, but the command text extraction is broken.
## How to Reproduce
1. In Element, mention the bot using a mention pill: @botname status. 2. Element sends plain body as `[@bot:server](https://matrix.to/#/@bot:server) status`. 3. Observe that the bot routes to LLM instead of the deterministic status command handler.
## Actual Result
strip_bot_mention returns the original text unchanged. The command name is parsed as the entire Markdown link. No deterministic command matches. Message falls through to LLM.
## Expected Result
strip_bot_mention strips the Markdown mention pill `[...](https://matrix.to/...)` and returns `status`. The deterministic command handler matches and handles it.
## Acceptance Criteria
- [ ] strip_bot_mention in chat/util.rs handles the Markdown mention pill format [display](https://matrix.to/#/@user:server)
- [ ] Deterministic commands like 'status', 'help', 'overview' work when sent via Element mention pills
- [ ] Existing plain-text mention formats (@bot:server command, @bot command, BotName command) continue to work
- [ ] Tests added for Markdown mention pill format in util.rs
@@ -0,0 +1,34 @@
---
name: "strip_bot_mention fails on Element Markdown mention pill format"
---
# Bug 460: strip_bot_mention fails on Element Markdown mention pill format
## Description
When Element sends a mention pill, the plain text `body` field contains a Markdown-style link like `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status`. The `strip_bot_mention` function uses prefix matching, so it tries to match `@timmy:crashlabs.io`, `@timmy`, and `Timmy` against text starting with `[` — none match. The entire message falls through to the LLM as a non-command.
`mentions_bot` works because it uses `body.contains(full_id)` which finds the MXID embedded inside the Markdown link. But `strip_bot_mention` fails because the text starts with `[`, not `@` or the display name.
This causes all deterministic bot commands (status, help, ambient, etc.) to be routed to the LLM instead of being handled by the bot when the user uses Element's mention pill (@-autocomplete).
## How to Reproduce
1. In Element, type `@timmy` and use the autocomplete pill to mention the bot
2. Append a command like `status`
3. Send the message
## Actual Result
The command falls through to the LLM. The bot logs show no "Handled bot command" entry. The plain body is `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status` which `strip_bot_mention` cannot parse.
## Expected Result
The bot should strip the Markdown mention link wrapper, extract the MXID or display name, and match the command deterministically. `@timmy status` via mention pill should produce the same pipeline status output as typing `@timmy status` manually.
## Acceptance Criteria
- [ ] strip_bot_mention handles Markdown link format `[display](https://matrix.to/#/@user:server) command` and extracts the command text
- [ ] Deterministic commands (status, help, ambient, etc.) work when invoked via Element mention pill autocomplete
- [ ] Unit tests cover the Markdown mention pill body format
- [ ] Existing strip_bot_mention tests still pass (plain @mention and display name formats)
@@ -0,0 +1,29 @@
---
name: "store.json created at project root instead of inside .storkit/"
---
# Bug 457: store.json created at project root instead of inside .storkit/
## Description
In main.rs, JsonFileStore is initialised with a hardcoded relative path `PathBuf::from("store.json")`, which creates the file in whatever directory the process was started from (typically the project root). It should live inside `.storkit/` alongside other runtime state files. The scaffold .gitignore also lists `store.json` as a root-level pattern rather than `.storkit/store.json`, and the scaffold comment/entries array in scaffold.rs explicitly lists `store.json` as a root-level file to ignore — both need updating.
## How to Reproduce
1. Run storkit in any project directory. 2. Observe that store.json is created at the project root rather than inside .storkit/.
## Actual Result
store.json is created at the working directory root, polluting the project root and not being gitignored by the scaffold-generated .gitignore unless the user happens to have a catch-all pattern.
## Expected Result
store.json is created at project_root/.storkit/store.json. The scaffold-generated .gitignore ignores .storkit/store.json. The scaffold comment and entries array in scaffold.rs no longer list store.json as a root-level file.
## Acceptance Criteria
- [ ] main.rs initialises JsonFileStore at project_root.join(".storkit").join("store.json") instead of PathBuf::from("store.json")
- [ ] scaffold.rs .gitignore entries updated: store.json root entry removed, .storkit/store.json added
- [ ] scaffold.rs comment on line ~333 updated to reflect store.json is no longer at the root
- [ ] wizard_tools.rs filter for store.json updated to match the new path if needed
- [ ] Existing deployments with a root-level store.json are not broken (storkit migrates or falls back gracefully)
@@ -0,0 +1,21 @@
---
name: "Matrix bot ignores messages addressed to other bots in ambient mode"
---
# Story 458: Matrix bot ignores messages addressed to other bots in ambient mode
## User Story
As a user with multiple bots in the same Matrix room, I want each bot to only respond to messages addressed to it in ambient mode, so that bots don't step on each other's responses.
## Acceptance Criteria
- [ ] In ambient mode, the bot ignores messages that begin with another bot's name or mention another bot's display name (e.g. 'sally: do X' or '@sally do X' is ignored by stu)
- [ ] In ambient mode, the bot still responds to messages with no explicit addressee
- [ ] In ambient mode, the bot still responds to messages explicitly addressed to itself (e.g. 'stu: do X' or '@stu do X')
- [ ] Direct @mention of the bot's Matrix user ID always triggers a response regardless of ambient mode
- [ ] The bot's own display_name from bot.toml is used to detect when it is being addressed
## Out of Scope
- TBD
@@ -0,0 +1,27 @@
---
name: "matrix_history.json and timers.json missing from scaffold .storkit/.gitignore"
---
# Bug 459: matrix_history.json and timers.json missing from scaffold .storkit/.gitignore
## Description
The scaffold's write_story_kit_gitignore function in scaffold.rs does not include matrix_history.json or timers.json in the .storkit/.gitignore entries. Both files are runtime state that should not be committed to git. matrix_device_id and matrix_store/ are already covered, but matrix_history.json (conversation history) and timers.json (timer store) are missing.
## How to Reproduce
1. Run storkit scaffold on a new project. 2. Start the Matrix bot. 3. Observe that matrix_history.json and timers.json are created inside .storkit/ but are not gitignored.
## Actual Result
matrix_history.json and timers.json appear as untracked files in git status.
## Expected Result
Both files are listed in .storkit/.gitignore and do not appear in git status.
## Acceptance Criteria
- [ ] matrix_history.json added to the entries array in write_story_kit_gitignore in scaffold.rs
- [ ] timers.json added to the entries array in write_story_kit_gitignore in scaffold.rs
- [ ] scaffold test in scaffold_creates_story_kit_gitignore_with_relative_entries asserts both entries are present
Generated
+9 -9
View File
@@ -1779,9 +1779,9 @@ dependencies = [
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.1", "hashbrown 0.16.1",
@@ -4077,7 +4077,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "storkit" name = "storkit"
version = "0.8.7" version = "0.8.8"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"async-trait", "async-trait",
@@ -4356,9 +4356,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.50.0" version = "1.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -4373,9 +4373,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-macros" name = "tokio-macros"
version = "2.6.1" version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -5630,9 +5630,9 @@ dependencies = [
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.2" version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "x25519-dalek" name = "x25519-dalek"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "living-spec-standalone", "name": "living-spec-standalone",
"version": "0.8.7", "version": "0.8.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "living-spec-standalone", "name": "living-spec-standalone",
"version": "0.8.7", "version": "0.8.8",
"dependencies": { "dependencies": {
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0", "react": "^19.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "living-spec-standalone", "name": "living-spec-standalone",
"private": true, "private": true,
"version": "0.8.7", "version": "0.8.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "storkit" name = "storkit"
version = "0.8.7" version = "0.8.8"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"
@@ -73,6 +73,72 @@ pub(super) async fn is_reply_to_bot(
candidate_ids.iter().any(|id| guard.contains(*id)) candidate_ids.iter().any(|id| guard.contains(*id))
} }
/// Returns `true` when the message body appears to be explicitly addressed to
/// someone **other** than this bot.
///
/// Recognised address patterns at the start of the body:
/// - `"name: rest"` — display-name style (e.g. `"sally: do X"`)
/// - `"@name rest"` — @ mention style (e.g. `"@sally do X"`)
///
/// A message is only considered addressed to another party when the name does
/// **not** match either the bot's `bot_name` (case-insensitive) or the
/// localpart of its `bot_user_id`.
///
/// Used in ambient mode to suppress responses when a message is clearly
/// directed at a different participant (e.g. another bot in the same room).
pub fn is_addressed_to_other(body: &str, bot_user_id: &OwnedUserId, bot_name: &str) -> bool {
let trimmed = body.trim_start();
let lower = trimmed.to_lowercase();
let bot_name_lower = bot_name.to_lowercase();
let bot_localpart = bot_user_id.localpart().to_lowercase();
// Pattern A: "@name …" at the start of the message.
// Handles both "@localpart" and "@localpart:homeserver" forms.
if let Some(rest) = lower.strip_prefix('@') {
// Extract everything up to the first whitespace character.
let word_end = rest
.find(|c: char| c.is_whitespace())
.unwrap_or(rest.len());
let mention = &rest[..word_end]; // e.g. "sally" or "sally:example.com"
// Strip the homeserver part to get just the localpart.
let localpart = mention.split(':').next().unwrap_or(mention);
if localpart.is_empty() {
return false; // bare "@" — not an address
}
if localpart == bot_localpart {
return false; // addressed to us
}
return true; // addressed to someone else
}
// Pattern B: "name: rest" — display-name style.
// Only the text before the *first* colon is inspected. We require that
// the prefix contains no spaces so that ordinary sentences such as
// "Here is a question: …" are not misread as bot addresses.
if let Some(colon_pos) = lower.find(':') {
let prefix = &lower[..colon_pos];
// Single-word prefix (no spaces).
if !prefix.contains(' ') && !prefix.is_empty() {
if prefix == bot_name_lower || prefix == bot_localpart {
return false; // addressed to us
}
return true; // addressed to someone else
}
// Multi-word prefix: only treat as an address if it is an exact
// case-insensitive match for our display name.
if prefix == bot_name_lower {
return false; // addressed to us
}
// Otherwise the colon is part of a regular sentence — not an address.
}
false
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -195,4 +261,92 @@ mod tests {
assert!(is_reply_to_bot(relates_to.as_ref(), &sent).await); assert!(is_reply_to_bot(relates_to.as_ref(), &sent).await);
} }
// -- is_addressed_to_other ----------------------------------------------
#[test]
fn addressed_to_other_display_name_colon() {
// "sally: do X" — addressed to sally, not our bot (stu)
let uid = make_user_id("@stu:homeserver.local");
assert!(is_addressed_to_other("sally: do X", &uid, "stu"));
}
#[test]
fn addressed_to_other_at_mention() {
// "@sally do X" — addressed to sally, not our bot (stu)
let uid = make_user_id("@stu:homeserver.local");
assert!(is_addressed_to_other("@sally do X", &uid, "stu"));
}
#[test]
fn addressed_to_other_at_mention_full_id() {
// "@sally:homeserver.local do X" — localpart is still "sally"
let uid = make_user_id("@stu:homeserver.local");
assert!(is_addressed_to_other(
"@sally:homeserver.local do X",
&uid,
"stu"
));
}
#[test]
fn not_addressed_to_other_self_display_name() {
// "stu: do X" — addressed to us
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other("stu: do X", &uid, "stu"));
}
#[test]
fn not_addressed_to_other_self_at_mention() {
// "@stu do X" — addressed to us
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other("@stu do X", &uid, "stu"));
}
#[test]
fn not_addressed_to_other_self_at_mention_full_id() {
// "@stu:homeserver.local do X" — addressed to us
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other(
"@stu:homeserver.local do X",
&uid,
"stu"
));
}
#[test]
fn not_addressed_to_other_no_addressee() {
// No explicit addressee — ambient message for everyone
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other(
"what's the status of the pipeline?",
&uid,
"stu"
));
}
#[test]
fn not_addressed_to_other_sentence_with_colon() {
// Regular sentence with colon — not an address
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other(
"here is the answer: it depends",
&uid,
"stu"
));
}
#[test]
fn not_addressed_to_other_display_name_case_insensitive() {
// "STU: do X" — case-insensitive match against our name "stu"
let uid = make_user_id("@stu:homeserver.local");
assert!(!is_addressed_to_other("STU: do X", &uid, "stu"));
}
#[test]
fn addressed_to_other_case_insensitive_other_name() {
// "SALLY: do X" — addressed to sally, not us
let uid = make_user_id("@stu:homeserver.local");
assert!(is_addressed_to_other("SALLY: do X", &uid, "stu"));
}
} }
@@ -19,7 +19,7 @@ use tokio::sync::watch;
use super::context::BotContext; use super::context::BotContext;
use super::format::markdown_to_html; use super::format::markdown_to_html;
use super::history::{ConversationEntry, ConversationRole, save_history}; use super::history::{ConversationEntry, ConversationRole, save_history};
use super::mentions::{is_reply_to_bot, mentions_bot}; use super::mentions::{is_addressed_to_other, is_reply_to_bot, mentions_bot};
use super::verification::check_sender_verified; use super::verification::check_sender_verified;
/// Build the user-facing prompt for a single turn. In multi-user rooms the /// Build the user-facing prompt for a single turn. In multi-user rooms the
@@ -93,6 +93,19 @@ pub(super) async fn on_room_message(
return; return;
} }
// In ambient mode, ignore messages that are explicitly addressed to a
// different entity (e.g. "sally: do X" or "@sally do X" when we are stu).
// We still let through messages addressed to us and the "ambient on" command.
if is_ambient && !is_addressed && !is_ambient_on
&& is_addressed_to_other(&body, &ctx.bot_user_id, &ctx.bot_name)
{
slog!(
"[matrix-bot] Ignoring ambient message addressed to another bot (sender={})",
ev.sender
);
return;
}
// Reject commands from unencrypted rooms — E2EE is mandatory. // Reject commands from unencrypted rooms — E2EE is mandatory.
if !room.encryption_state().is_encrypted() { if !room.encryption_state().is_encrypted() {
slog!( slog!(
@@ -103,7 +103,6 @@ pub(super) async fn on_room_verification_request(
ev: OriginalSyncRoomMessageEvent, ev: OriginalSyncRoomMessageEvent,
client: Client, client: Client,
) { ) {
slog!("[matrix-bot] DEBUG room msg from {} msgtype={:?}", ev.sender, ev.content.msgtype);
// Only act on in-room verification request messages. // Only act on in-room verification request messages.
if !matches!(ev.content.msgtype, MessageType::VerificationRequest(_)) { if !matches!(ev.content.msgtype, MessageType::VerificationRequest(_)) {
return; return;
+88 -8
View File
@@ -49,9 +49,30 @@ pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
/// - `DisplayName: rest` → `rest` (Element tab-completion inserts a colon) /// - `DisplayName: rest` → `rest` (Element tab-completion inserts a colon)
/// - `DisplayName, rest` → `rest` (Element tab-completion may insert a comma) /// - `DisplayName, rest` → `rest` (Element tab-completion may insert a comma)
/// - `DisplayName ⚡️: rest` → `rest` (display name with emoji) /// - `DisplayName ⚡️: rest` → `rest` (display name with emoji)
/// - `[DisplayName](https://matrix.to/#/@user:server) rest` → `rest` (Element mention pill)
pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim(); let trimmed = message.trim();
// Try Element Markdown mention pill format:
// "[DisplayName](https://matrix.to/#/@user:server) rest"
if trimmed.starts_with('[') {
if let Some(after_label) = trimmed.find("](https://matrix.to/#/") {
let url_start = after_label + 2; // skip "]("
let url_content = &trimmed[url_start..]; // "https://matrix.to/#/@user:server) rest"
if let Some(close_paren) = url_content.find(')') {
let url = &url_content[..close_paren]; // "https://matrix.to/#/@user:server"
let matrix_prefix = "https://matrix.to/#/";
if url.starts_with(matrix_prefix) {
let mentioned_id = &url[matrix_prefix.len()..];
if mentioned_id.eq_ignore_ascii_case(bot_user_id) {
let rest = &url_content[close_paren + 1..];
return strip_mention_separator(rest);
}
}
}
}
}
// Try full Matrix user ID (e.g. "@timmy:homeserver.local") // Try full Matrix user ID (e.g. "@timmy:homeserver.local")
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return strip_mention_separator(rest); return strip_mention_separator(rest);
@@ -72,16 +93,19 @@ pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str
trimmed trimmed
} }
/// Strip an optional Element tab-completion separator (`:` or `,`) and /// Strip decoration between a bot mention and the command text.
/// surrounding whitespace from the start of text that follows a bot mention.
/// ///
/// Element's tab-completion inserts `DisplayName: ` (colon + space) after the /// After the bot name/ID is stripped, what remains may include whitespace,
/// name. Without this strip the leading `:` would be treated as part of the /// emoji from display names (e.g. `Timmy ⚡️`), and Element tab-completion
/// command name and no command would match. /// separators (`:` or `,`). This function skips all of that and returns a
/// slice starting at the first ASCII alphanumeric character (the command).
fn strip_mention_separator(rest: &str) -> &str { fn strip_mention_separator(rest: &str) -> &str {
let rest = rest.trim_start(); let byte_skip = rest
let rest = rest.strip_prefix([',', ':']).unwrap_or(rest); .char_indices()
rest.trim_start() .find(|(_, c)| c.is_ascii_alphanumeric())
.map(|(i, _)| i)
.unwrap_or(rest.len());
&rest[byte_skip..]
} }
/// Returns `true` when `text` ends while inside an open fenced code block. /// Returns `true` when `text` ends while inside an open fenced code block.
@@ -381,6 +405,62 @@ mod tests {
assert_eq!(rest, "help"); assert_eq!(rest, "help");
} }
#[test]
fn strip_mention_short_name_emoji_suffix_in_body() {
// bot_name is "Timmy" (no emoji) but Element mention pill puts
// "Timmy ⚡️ status" in the body — the emoji is part of the display
// name as set on the Matrix server, not in bot.toml.
let rest = strip_bot_mention("Timmy ⚡️ status", "Timmy", "@timmy:homeserver.local");
assert_eq!(rest, "status");
}
#[test]
fn strip_mention_element_markdown_pill_format() {
// Element sends "[DisplayName](https://matrix.to/#/@user:server) command"
// when a user uses the @ autocomplete mention pill.
let rest = strip_bot_mention(
"[Timmy](https://matrix.to/#/@timmy:homeserver.local) status",
"Timmy",
"@timmy:homeserver.local",
);
assert_eq!(rest, "status");
}
#[test]
fn strip_mention_element_markdown_pill_with_emoji_display_name() {
let rest = strip_bot_mention(
"[timmy ⚡️](https://matrix.to/#/@timmy:homeserver.local) ambient on",
"timmy ⚡️",
"@timmy:homeserver.local",
);
assert_eq!(rest, "ambient on");
}
#[test]
fn strip_mention_element_markdown_pill_wrong_user_id_no_strip() {
// Pill for a different user should not be stripped.
let rest = strip_bot_mention(
"[Other](https://matrix.to/#/@other:homeserver.local) status",
"Timmy",
"@timmy:homeserver.local",
);
assert_eq!(
rest,
"[Other](https://matrix.to/#/@other:homeserver.local) status"
);
}
#[test]
fn strip_mention_element_markdown_pill_no_trailing_command() {
// Pill with no command after it returns empty string (handled by callers).
let rest = strip_bot_mention(
"[Timmy](https://matrix.to/#/@timmy:homeserver.local)",
"Timmy",
"@timmy:homeserver.local",
);
assert_eq!(rest, "");
}
// -- drain_complete_paragraphs ------------------------------------------ // -- drain_complete_paragraphs ------------------------------------------
#[test] #[test]
-1
View File
@@ -168,7 +168,6 @@ pub(crate) fn is_bare_project(project_root: &Path) -> bool {
|| n == "LICENSE" || n == "LICENSE"
|| n == "README.md" || n == "README.md"
|| n == "script" || n == "script"
|| n == "store.json"
}) })
}) })
.unwrap_or(true) .unwrap_or(true)
+13 -3
View File
@@ -285,6 +285,8 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
"bot.toml", "bot.toml",
"matrix_store/", "matrix_store/",
"matrix_device_id", "matrix_device_id",
"matrix_history.json",
"timers.json",
"worktrees/", "worktrees/",
"merge_workspace/", "merge_workspace/",
"coverage/", "coverage/",
@@ -294,6 +296,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
"logs/", "logs/",
"token_usage.jsonl", "token_usage.jsonl",
"wizard_state.json", "wizard_state.json",
"store.json",
]; ];
let gitignore_path = root.join(".storkit").join(".gitignore"); let gitignore_path = root.join(".storkit").join(".gitignore");
@@ -330,11 +333,13 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
} }
/// Append root-level Story Kit entries to the project `.gitignore`. /// Append root-level Story Kit entries to the project `.gitignore`.
/// Only `store.json` and `.storkit_port` remain here because they live at /// Only `.storkit_port` and `.mcp.json` remain here because they live at
/// the project root and git does not support `../` patterns in `.gitignore` /// the project root and git does not support `../` patterns in `.gitignore`
/// files, so they cannot be expressed in `.storkit/.gitignore`. /// files, so they cannot be expressed in `.storkit/.gitignore`.
/// `store.json` is excluded via `.storkit/.gitignore` since it now lives
/// inside the `.storkit/` directory.
fn append_root_gitignore_entries(root: &Path) -> Result<(), String> { fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
let entries = [".storkit_port", "store.json", ".mcp.json"]; let entries = [".storkit_port", ".mcp.json"];
let gitignore_path = root.join(".gitignore"); let gitignore_path = root.join(".gitignore");
let existing = if gitignore_path.exists() { let existing = if gitignore_path.exists() {
@@ -699,17 +704,22 @@ mod tests {
assert!(sk_content.contains("worktrees/")); assert!(sk_content.contains("worktrees/"));
assert!(sk_content.contains("merge_workspace/")); assert!(sk_content.contains("merge_workspace/"));
assert!(sk_content.contains("coverage/")); assert!(sk_content.contains("coverage/"));
assert!(sk_content.contains("matrix_history.json"));
assert!(sk_content.contains("timers.json"));
// Must NOT contain absolute .storkit/ prefixed paths // Must NOT contain absolute .storkit/ prefixed paths
assert!(!sk_content.contains(".storkit/")); assert!(!sk_content.contains(".storkit/"));
// Root .gitignore must contain root-level storkit entries // Root .gitignore must contain root-level storkit entries
let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(root_content.contains(".storkit_port")); assert!(root_content.contains(".storkit_port"));
assert!(root_content.contains("store.json")); // store.json now lives inside .storkit/ and must NOT appear in root .gitignore
assert!(!root_content.contains("store.json"));
// Root .gitignore must NOT contain .storkit/ sub-directory patterns // Root .gitignore must NOT contain .storkit/ sub-directory patterns
assert!(!root_content.contains(".storkit/worktrees/")); assert!(!root_content.contains(".storkit/worktrees/"));
assert!(!root_content.contains(".storkit/merge_workspace/")); assert!(!root_content.contains(".storkit/merge_workspace/"));
assert!(!root_content.contains(".storkit/coverage/")); assert!(!root_content.contains(".storkit/coverage/"));
// store.json must be in .storkit/.gitignore instead
assert!(sk_content.contains("store.json"));
} }
#[test] #[test]
+11 -1
View File
@@ -139,8 +139,18 @@ async fn main() -> Result<(), std::io::Error> {
let app_state = Arc::new(SessionState::default()); let app_state = Arc::new(SessionState::default());
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
// Migrate legacy root-level store.json into .storkit/ if the new path does
// not yet exist. This keeps existing deployments working after upgrade.
let legacy_store_path = cwd.join("store.json");
let store_path = cwd.join(".storkit").join("store.json");
if legacy_store_path.exists() && !store_path.exists() {
if let Some(parent) = store_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::rename(&legacy_store_path, &store_path);
}
let store = Arc::new( let store = Arc::new(
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?, JsonFileStore::from_path(store_path).map_err(std::io::Error::other)?,
); );
// Collect CLI args, skipping the binary name (argv[0]). // Collect CLI args, skipping the binary name (argv[0]).