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
10 changes: 8 additions & 2 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Scope labels for actions/labeler@v6
# Maps file path globs to scope labels. Multiple labels can apply per PR.
# Labels for actions/labeler@v6
# Maps file path globs to labels. Multiple labels can apply per PR.

"scope: agent":
- changed-files:
Expand Down Expand Up @@ -164,3 +164,9 @@
- any-glob-to-any-file:
- Cargo.toml
- Cargo.lock

"DB MIGRATION":
- changed-files:
- any-glob-to-any-file:
- migrations/**
- src/db/libsql_migrations.rs
3 changes: 3 additions & 0 deletions .github/scripts/create-labels.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ create "scope: ci" "546E7A" "CI/CD workflows"
create "scope: docs" "78909C" "Documentation"
create "scope: dependencies" "90A4AE" "Dependency updates"

echo "==> Creating coordination labels..."
create "DB MIGRATION" "C62828" "PR adds or modifies PostgreSQL or libSQL migration definitions"

echo "==> Creating workflow labels..."
create "skip-regression-check" "9E9E9E" "Acknowledged: fix without regression test"

Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/pr-label-scope.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ on:

permissions:
contents: read
issues: write
pull-requests: write

jobs:
scope:
runs-on: ubuntu-latest
steps:
- name: Ensure DB MIGRATION label exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: gh label create "DB MIGRATION" --repo "$REPO" --color C62828 --description "PR adds or modifies PostgreSQL or libSQL migration definitions" --force

- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
with:
configuration-path: .github/labeler.yml
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,17 @@ jobs:
# shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty
gh release create "$RELEASE_TAG" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*

# Build and push Docker Hub images (:version, :latest, :sha-*) after the GitHub Release exists.
docker-image:
needs: host
if: ${{ always() && needs.host.result == 'success' }}
permissions:
contents: read
packages: read
actions: write
uses: ./.github/workflows/docker.yml
secrets: inherit

# Commit patched manifest SHA256 checksums back to main so the repo
# stays in sync with the released artifacts.
update-registry-checksums:
Expand Down
71 changes: 58 additions & 13 deletions channels-src/telegram/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,26 @@ const TELEGRAM_STATUS_MAX_CHARS: usize = 600;
/// Telegram's hard limit for message text length.
const TELEGRAM_MAX_MESSAGE_LEN: usize = 4096;

fn utf16_code_unit_len(text: &str) -> usize {
text.encode_utf16().count()
}

fn prefix_within_utf16_limit(text: &str, max_units: usize) -> usize {
let mut units = 0;
let mut end = 0;

for (byte_idx, ch) in text.char_indices() {
let ch_units = ch.len_utf16();
if units + ch_units > max_units {
break;
}
units += ch_units;
end = byte_idx + ch.len_utf8();
}

end
}

fn truncate_status_message(input: &str, max_chars: usize) -> String {
let mut iter = input.chars();
let truncated: String = iter.by_ref().take(max_chars).collect();
Expand All @@ -386,7 +406,7 @@ fn truncate_status_message(input: &str, max_chars: usize) -> String {
}
}

/// Split a long message into chunks that fit within Telegram's 4096-char limit.
/// Split a long message into chunks that fit within Telegram's 4096 UTF-16-unit limit.
///
/// Tries to split at the most natural boundary available (in priority order):
/// 1. Double newline (paragraph break)
Expand All @@ -395,28 +415,36 @@ fn truncate_status_message(input: &str, max_chars: usize) -> String {
/// 4. Word boundary (space)
/// 5. Hard cut at the limit (last resort for pathological input)
fn split_message(text: &str) -> Vec<String> {
if text.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN {
if utf16_code_unit_len(text) <= TELEGRAM_MAX_MESSAGE_LEN {
return vec![text.to_string()];
}

let mut chunks: Vec<String> = Vec::new();
let mut remaining = text;

while !remaining.is_empty() {
// Count chars to find the byte offset for our window.
let window_bytes = remaining
.char_indices()
.take(TELEGRAM_MAX_MESSAGE_LEN)
.last()
.map(|(byte_idx, ch)| byte_idx + ch.len_utf8())
.unwrap_or(remaining.len());
// Find the longest UTF-8 prefix that fits within Telegram's UTF-16 limit.
let window_bytes = prefix_within_utf16_limit(remaining, TELEGRAM_MAX_MESSAGE_LEN);

if window_bytes >= remaining.len() {
// Remainder fits entirely.
chunks.push(remaining.to_string());
break;
}

if window_bytes == 0 {
// Defensive fallback: make progress even if a future caller uses a
// smaller limit than a single scalar value can fit within.
let first_char_len = remaining
.chars()
.next()
.map(|ch| ch.len_utf8())
.unwrap_or(remaining.len());
chunks.push(remaining[..first_char_len].to_string());
remaining = &remaining[first_char_len..];
continue;
}

let window = &remaining[..window_bytes];

// 1. Double newline — best paragraph boundary
Expand Down Expand Up @@ -2268,6 +2296,10 @@ export!(TelegramChannel);
mod tests {
use super::*;

fn utf16_len(text: &str) -> usize {
text.encode_utf16().count()
}

#[test]
fn test_split_message_short() {
let text = "Hello, world!";
Expand Down Expand Up @@ -2295,7 +2327,7 @@ mod tests {
let chunks = split_message(&text);
assert!(chunks.len() > 1, "expected multiple chunks");
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
assert!(utf16_len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN);
}
// Rejoined chunks must equal the original text exactly.
let rejoined = chunks.join(" ");
Expand All @@ -2311,7 +2343,7 @@ mod tests {
assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
assert!(utf16_len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN);
}
}

Expand Down Expand Up @@ -2341,7 +2373,7 @@ mod tests {
let chunks = split_message(&text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
assert!(utf16_len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN);
}
// Rejoined must preserve all characters
let rejoined: String = chunks.concat();
Expand All @@ -2358,12 +2390,25 @@ mod tests {
let chunks = split_message(&text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
assert!(utf16_len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN);
// Every char should be a complete emoji
assert!(chunk.chars().all(|c| c == '\u{1F600}'));
}
}

#[test]
fn test_split_message_exact_utf16_limit_for_surrogate_pairs() {
let emoji = "\u{1F600}"; // 😀
let text = emoji.repeat(TELEGRAM_MAX_MESSAGE_LEN);

let chunks = split_message(&text);

assert_eq!(chunks.len(), 2);
assert!(chunks
.iter()
.all(|chunk| utf16_len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN));
}

#[test]
fn test_clean_message_text() {
// Without bot_username: strips any leading @mention
Expand Down
16 changes: 11 additions & 5 deletions crates/ironclaw_gateway/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7084,6 +7084,12 @@ function loadSettingsSubtab(subtab) {
// --- Structured Settings Definitions ---

var INFERENCE_SETTINGS = [
{
group: 'cfg.group.inference',
settings: [
{ key: 'temperature', label: 'cfg.temperature.label', description: 'cfg.temperature.desc', type: 'float', min: 0, max: 2, step: 0.1 },
]
},
{
group: 'cfg.group.embeddings',
settings: [
Expand Down Expand Up @@ -7429,25 +7435,25 @@ function renderStructuredSettingsRow(def, value, activeValue) {
return function() { saveSetting(k, el.value === '' ? null : el.value); };
})(def.key, sel));
inputWrap.appendChild(sel);
} else if (def.type === 'number') {
} else if (def.type === 'number' || def.type === 'float') {
var numInp = document.createElement('input');
numInp.type = 'number';
numInp.step = '1';
numInp.step = def.step !== undefined ? String(def.step) : (def.type === 'float' ? 'any' : '1');
numInp.className = 'settings-input';
numInp.setAttribute('aria-label', ariaLabel);
numInp.value = (value === null || value === undefined) ? '' : value;
if (!value && value !== 0) numInp.placeholder = placeholderText;
if (def.min !== undefined) numInp.min = def.min;
if (def.max !== undefined) numInp.max = def.max;
numInp.addEventListener('change', (function(k, el) {
numInp.addEventListener('change', (function(k, el, isFloat) {
return function() {
if (el.value === '') return saveSetting(k, null);
var parsed = parseInt(el.value, 10);
var parsed = isFloat ? parseFloat(el.value) : parseInt(el.value, 10);
if (isNaN(parsed)) return;
el.value = parsed;
saveSetting(k, parsed);
};
})(def.key, numInp));
})(def.key, numInp, def.type === 'float'));
inputWrap.appendChild(numInp);
} else if (def.type === 'list') {
var listInp = document.createElement('input');
Expand Down
3 changes: 3 additions & 0 deletions crates/ironclaw_gateway/static/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,9 @@ I18n.register('en', {
'cfg.llm_backend.desc': 'LLM inference provider',
'cfg.selected_model.label': 'Model',
'cfg.selected_model.desc': 'Model name or ID for the selected backend',
'cfg.temperature.label': 'Temperature',
'cfg.temperature.desc': 'Default sampling temperature (0.0–2.0). Lower = more deterministic, higher = more creative',
'cfg.group.inference': 'Inference',
'cfg.ollama_base_url.label': 'Ollama URL',
'cfg.ollama_base_url.desc': 'Base URL for Ollama API',
'cfg.openai_compatible_base_url.label': 'OpenAI-compatible URL',
Expand Down
3 changes: 3 additions & 0 deletions crates/ironclaw_gateway/static/i18n/ko.js
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,9 @@ I18n.register('ko', {
'cfg.llm_backend.desc': 'LLM 추론 공급자',
'cfg.selected_model.label': '모델',
'cfg.selected_model.desc': '선택한 백엔드의 모델 이름 또는 ID',
'cfg.temperature.label': '온도',
'cfg.temperature.desc': '기본 샘플링 온도 (0.0–2.0). 낮을수록 결정적, 높을수록 창의적',
'cfg.group.inference': '추론',
'cfg.ollama_base_url.label': 'Ollama URL',
'cfg.ollama_base_url.desc': 'Ollama API의 베이스 URL',
'cfg.openai_compatible_base_url.label': 'OpenAI 호환 URL',
Expand Down
3 changes: 3 additions & 0 deletions crates/ironclaw_gateway/static/i18n/zh-CN.js
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,9 @@ I18n.register('zh-CN', {
'cfg.llm_backend.desc': 'LLM 推理提供商',
'cfg.selected_model.label': '模型',
'cfg.selected_model.desc': '所选后端的模型名称或 ID',
'cfg.temperature.label': '温度',
'cfg.temperature.desc': '默认采样温度(0.0–2.0)。越低越确定性,越高越有创意',
'cfg.group.inference': '推理',
'cfg.ollama_base_url.label': 'Ollama URL',
'cfg.ollama_base_url.desc': 'Ollama API 基础 URL',
'cfg.openai_compatible_base_url.label': 'OpenAI 兼容 URL',
Expand Down
Loading
Loading