Add command files and enhance session management features

This commit is contained in:
ImBenji
2026-04-28 19:00:27 +01:00
parent 3588783001
commit 728c0ffe81
146 changed files with 6854 additions and 7783 deletions
+202 -28
View File
@@ -3,6 +3,7 @@ import "dart:convert";
import "package:flutter/foundation.dart";
import "../api/openrouter_client.dart";
import "../api/response_parser.dart";
import "../compact/compact_service.dart";
import "../hooks/hook_runner.dart";
import "../hooks/hook_types.dart";
@@ -32,11 +33,19 @@ class SessionRuntime {
required LocalSettings Function() getSettings,
required String Function(String?) normalizeModelId,
required VoidCallback onChanged,
void Function(double costDelta)? onCostAdded,
bool Function()? isActive,
void Function(String sessionId, String name)? onNameGenerated,
Future<void> Function(String rule)? onPersistAllowRule,
}) : _toolLoopService = toolLoopService,
_hookRunner = hookRunner,
_getSettings = getSettings,
_normalizeModelId = normalizeModelId,
_onChanged = onChanged {
_onChanged = onChanged,
_onCostAdded = onCostAdded,
_isActive = isActive,
_onNameGenerated = onNameGenerated,
_onPersistAllowRule = onPersistAllowRule {
_conversationHistory = ConversationHistory(session: session);
_apiMessages = _buildApiMessages(session.messages);
// restore persisted per-thread mode override
@@ -46,8 +55,14 @@ class SessionRuntime {
final ToolLoopService _toolLoopService;
final HookRunner? _hookRunner;
final VoidCallback _onChanged;
final void Function(double costDelta)? _onCostAdded;
final LocalSettings Function() _getSettings;
final String Function(String?) _normalizeModelId;
final bool Function()? _isActive;
final void Function(String sessionId, String name)? _onNameGenerated;
final Future<void> Function(String rule)? _onPersistAllowRule;
bool _nameGenerated = false;
late final ConversationHistory _conversationHistory;
late List<Map<String, dynamic>> _apiMessages;
@@ -79,6 +94,10 @@ class SessionRuntime {
// set when a turn finishes while the user is viewing a different thread
bool _hasUnreadResult = false;
// true while a streaming tool is actively pushing chunks — prevents onToolResult
// from double-adding the content that was already appended chunk by chunk
bool _streamingToolOutput = false;
// compact state
String? _lastCompactSummary;
bool _suppressCompactWarning = false;
@@ -102,6 +121,11 @@ class SessionRuntime {
PendingPermission? get pendingPermission => _pendingPermission;
bool get hasUnreadResult => _hasUnreadResult;
void setUnreadResult(bool value) {
_hasUnreadResult = value;
_onChanged();
}
void markRead() {
if (!_hasUnreadResult) return;
_hasUnreadResult = false;
@@ -204,6 +228,9 @@ class SessionRuntime {
bool hasStreamingAssistantMessage = false;
_client = await OpenRouterClientFactory.create(apiKey: apiKey);
// detect first turn before the user message is added
final bool isFirstTurn = _apiMessages.isEmpty && !_nameGenerated;
final session = _conversationHistory.session;
if (session != null) {
session.model = model;
@@ -282,13 +309,28 @@ class SessionRuntime {
);
_onChanged();
},
onToolResult: (toolName, result) {
_conversationHistory.addMessage(
"tool",
_formatToolResult(toolName, result),
);
onToolOutputChunk: (toolName, chunk) {
// append live chunk to the last tool message (which onToolCall just added)
_streamingToolOutput = true;
_conversationHistory.appendToLastMessage(chunk);
_onChanged();
},
onToolResult: (toolName, result) {
if (_streamingToolOutput) {
// content already in the message from live chunks — dont double-add
_streamingToolOutput = false;
} else {
_conversationHistory.addMessage(
"tool",
_formatToolResult(toolName, result),
);
}
_onChanged();
// save after each tool result so progress isnt lost if app dies mid-turn
final s = _conversationHistory.session;
if (s != null) SessionStore.instance.saveSession(s);
},
onAssistantTextDelta: (delta) {
if (!hasStreamingAssistantMessage) {
_conversationHistory.addMessage("assistant", "");
@@ -300,6 +342,10 @@ class SessionRuntime {
onAssistantMessageComplete: () {
hasStreamingAssistantMessage = false;
_onChanged();
// save after each complete assistant message (streaming done)
final s = _conversationHistory.session;
if (s != null) SessionStore.instance.saveSession(s);
},
onPermissionRequired: (toolName, input, {String? suggestionRule}) async {
final pending = PendingPermission(
@@ -325,27 +371,58 @@ class SessionRuntime {
final ct = toolLoopResult.response.contextTokens;
final rawUsage = toolLoopResult.response.usage;
final responseCost = (rawUsage?["cost"] as num?)?.toDouble() ?? 0.0;
double advisorCostTotal = 0;
for (final au in toolLoopResult.advisorUsages) {
cost_tracker.addToTotalSessionCost(
cost: au.costUsd,
inputTokens: au.inputTokens,
outputTokens: au.outputTokens,
cacheReadTokens: 0,
cacheCreationTokens: 0,
model: au.model,
);
advisorCostTotal += au.costUsd;
}
final totalCostThisTurn = responseCost + advisorCostTotal;
cost_tracker.addToTotalSessionCost(
cost: responseCost,
inputTokens: toolLoopResult.response.inputTokens ?? 0,
outputTokens: toolLoopResult.response.outputTokens ?? 0,
cacheReadTokens: toolLoopResult.response.cacheReadInputTokens ?? 0,
cacheCreationTokens: toolLoopResult.response.cacheCreationInputTokens ?? 0,
webSearchRequests: toolLoopResult.webSearchRequests,
webFetchRequests: toolLoopResult.webFetchRequests,
model: toolLoopResult.response.model,
);
if (!toolLoopResult.finalResponseWasStreamed) {
_conversationHistory.addMessage(
"assistant",
toolLoopResult.responseText,
tokens: toolLoopResult.response.outputTokens,
contextTokens: ct,
cost: totalCostThisTurn > 0 ? totalCostThisTurn : null,
);
} else {
_conversationHistory.setLastMessageContextTokens(ct);
_conversationHistory.setLastMessageCost(
totalCostThisTurn > 0 ? totalCostThisTurn : null,
);
}
cost_tracker.addToTotalSessionCost(
cost: 0.0,
inputTokens: toolLoopResult.response.inputTokens ?? 0,
outputTokens: toolLoopResult.response.outputTokens ?? 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
webSearchRequests: toolLoopResult.webSearchRequests,
webFetchRequests: toolLoopResult.webFetchRequests,
model: toolLoopResult.response.model,
);
if (totalCostThisTurn > 0) _onCostAdded?.call(totalCostThisTurn);
_onChanged();
// generate an AI name for the thread after the first turn
if (isFirstTurn && session != null && _onNameGenerated != null) {
_nameGenerated = true;
_generateThreadName(session, text, apiKey, model);
}
// auto-compact
if (ct > 0) {
@@ -405,7 +482,9 @@ class SessionRuntime {
_client = null;
_stopRequested = false;
_isLoading = false;
_hasUnreadResult = true;
if (_conversationHistory.session?.id != null && !(_isActive?.call() ?? false)) {
_hasUnreadResult = true;
}
_onChanged();
}
@@ -485,30 +564,58 @@ class SessionRuntime {
_suppressCompactWarning = true;
_consecutiveCompactFailures = 0;
// store a boundary marker so that on session restore we know where to
// cut the history for the api call. content = the summary string the
// model will see; full message history before this marker is kept for
// the user to scroll back through.
_conversationHistory.addMessage("compact_boundary", result.messages.first["content"] as String);
_conversationHistory.addMessage(
"assistant",
"✦ Conversation compacted (${result.preCompactMessageCount} messages → summary). "
"Context has been reset.",
);
final session = _conversationHistory.session;
if (session != null) {
await SessionStore.instance.saveSession(session);
}
// re-name the thread using the compact summary
if (session != null && _onNameGenerated != null) {
final settings = _getSettings();
final apiKey = settings.openRouterApiKey;
final model = _normalizeModelId(settings.model);
if (apiKey != null && apiKey.isNotEmpty) {
_generateThreadName(session, result.summaryText, apiKey, model);
}
}
_onChanged();
}
// ─── permission ─────────────────────────────────────────────────────────────
Future<void> resolvePermission(PermissionDecision decision) async {
Future<void> resolvePermission(PermissionDecision decision, {String? persistRule}) async {
final pending = _pendingPermission;
if (pending == null) return;
if (decision == PermissionDecision.allowAlways) {
final session = _conversationHistory.session;
if (session != null) {
final rule = _buildRuleString(pending.toolName, pending.input);
if (!session.alwaysAllowRules.contains(rule)) {
session.alwaysAllowRules.add(rule);
await SessionStore.instance.saveSession(session);
if (persistRule != null && _onPersistAllowRule != null) {
// persist to localSettings — survives session switches
await _onPersistAllowRule!(persistRule);
} else {
// session-scoped only (file tools)
final session = _conversationHistory.session;
if (session != null) {
final rule = pending.suggestionRule ?? _buildRuleString(pending.toolName, pending.input);
if (!session.alwaysAllowRules.contains(rule)) {
session.alwaysAllowRules.add(rule);
await SessionStore.instance.saveSession(session);
}
}
}
}
pending.resolve(decision);
@@ -526,10 +633,77 @@ class SessionRuntime {
// ─── helpers ────────────────────────────────────────────────────────────────
List<Map<String, dynamic>> _buildApiMessages(List<Message> messages) {
return messages
.where((m) => m.role == "user" || m.role == "assistant")
.map((m) => <String, dynamic>{"role": m.role, "content": m.content})
.toList(growable: true);
// find the last compact boundary (if any) — everything before it belongs
// to the old pre-compact history that the model shouldnt see again.
// the boundary's content is the summary string we send as the first user msg.
int lastBoundary = -1;
for (var i = messages.length - 1; i >= 0; i--) {
if (messages[i].role == "compact_boundary") {
lastBoundary = i;
break;
}
}
if (lastBoundary == -1) {
// no compaction yet — send everything
return messages
.where((m) => m.role == "user" || m.role == "assistant")
.map((m) => <String, dynamic>{"role": m.role, "content": m.content})
.toList(growable: true);
}
// start with the summary as a user message, then all user/assistant
// messages that came after the boundary
final result = <Map<String, dynamic>>[
{"role": "user", "content": messages[lastBoundary].content},
];
for (var i = lastBoundary + 1; i < messages.length; i++) {
final m = messages[i];
if (m.role == "user" || m.role == "assistant") {
result.add({"role": m.role, "content": m.content});
}
}
return result;
}
// fires async — does not block the caller. context is a short snippet
// (first user msg or compact summary) — NOT the full conversation history.
void _generateThreadName(ConversationSession session, String context, String apiKey, String model) {
() async {
try {
final client = await OpenRouterClientFactory.create(apiKey: apiKey);
try {
final snippet = context.length > 600 ? "${context.substring(0, 600)}..." : context;
final resp = await client.createMessage(
model: model,
maxTokens: 20,
messages: [
{"role": "user", "content": snippet},
],
system: "Generate a very short title (3-6 words) for this conversation. Reply with ONLY the title text — no quotes, no period at the end, nothing else.",
temperature: 0.3,
);
final name = ResponseParser.extractTextContent(resp)
.replaceAll(RegExp(r'["\n\r`]'), "")
.trim();
if (name.isEmpty || name.length > 80) return;
session.name = name;
await SessionStore.instance.saveSession(session);
_onNameGenerated?.call(session.id, name);
_onChanged();
} finally {
client.close();
}
} catch (e) {
print("[thread name] generation failed: $e");
}
}();
}
String _buildSessionName(String text) {