The-Agency/lib/src/local_state.dart

417 lines
12 KiB
Dart

import 'dart:convert';
import 'dart:io';
const supportedThemeSettings = <String>[
'auto',
'dark',
'light',
'light-daltonized',
'dark-daltonized',
'light-ansi',
'dark-ansi',
];
const supportedThemeNames = <String>[
'dark',
'light',
'light-daltonized',
'dark-daltonized',
'light-ansi',
'dark-ansi',
];
const supportedAgentColors = <String>[
'red',
'blue',
'green',
'yellow',
'purple',
'orange',
'pink',
'cyan',
];
const supportedEffortLevels = <String>['low', 'medium', 'high', 'max'];
const supportedPermissionModes = <String>[
'acceptEdits',
'auto',
'bubble',
'bypassPermissions',
'default',
'dontAsk',
'plan',
];
String getConfigHomeDir() {
final home = Platform.environment['HOME'];
if (home == null || home.isEmpty) {
return joinPath(Directory.current.path, '.clawd_code');
}
return joinPath(home, '.clawd_code');
}
String getSettingsFilePath() {
return joinPath(getConfigHomeDir(), 'settings.json');
}
String getPlansDirectoryPath() {
return joinPath(getConfigHomeDir(), 'plans');
}
String joinPath(String base, String child) {
if (base.endsWith(Platform.pathSeparator)) {
return '$base$child';
}
return '$base${Platform.pathSeparator}$child';
}
class LocalSettings {
const LocalSettings({
this.advisorModel,
this.alwaysAllowRules = const <String>[],
this.alwaysAskRules = const <String>[],
this.alwaysDenyRules = const <String>[],
this.editorMode = 'normal',
this.effortLevel,
this.fastMode = false,
this.hooks,
this.mcpServers,
this.model,
this.openRouterApiKey,
this.outputStyle,
this.permissionMode = 'default',
this.privacyLevel,
this.statusLinePrompt,
this.telemetry,
this.theme = 'dark',
});
factory LocalSettings.fromJson(Map<String, dynamic> json) {
return LocalSettings(
advisorModel: _readString(json, 'advisorModel'),
alwaysAllowRules: _readStringList(json, 'alwaysAllowRules'),
alwaysAskRules: _readStringList(json, 'alwaysAskRules'),
alwaysDenyRules: _readStringList(json, 'alwaysDenyRules'),
editorMode: _readString(json, 'editorMode') ?? 'normal',
effortLevel: _readString(json, 'effortLevel'),
fastMode: _readBool(json, 'fastMode') ?? false,
hooks: _readStringMap(json, 'hooks'),
mcpServers: _readMcpServers(json),
model: _readString(json, 'model'),
openRouterApiKey: _readString(json, 'openRouterApiKey'),
outputStyle: _readString(json, 'outputStyle'),
permissionMode: _readString(json, 'permissionMode') ?? 'default',
privacyLevel: _readString(json, 'privacyLevel'),
statusLinePrompt: _readString(json, 'statusLinePrompt'),
telemetry: _readString(json, 'telemetry'),
theme: _readString(json, 'theme') ?? 'dark',
);
}
// advisor model name - optional
final String? advisorModel;
final List<String> alwaysAllowRules;
final List<String> alwaysAskRules;
final List<String> alwaysDenyRules;
final String editorMode;
final String? effortLevel;
final bool fastMode;
// hook configs keyed by event name
final Map<String, String>? hooks;
// mcp server configs - each entry is a map of server name -> config object
final Map<String, Map<String, dynamic>>? mcpServers;
final String? model;
final String? openRouterApiKey;
final String? outputStyle;
final String permissionMode;
final String? privacyLevel;
final String? statusLinePrompt;
final String? telemetry;
final String theme;
LocalSettings copyWith({
Object? advisorModel = _sentinel,
List<String>? alwaysAllowRules,
List<String>? alwaysAskRules,
List<String>? alwaysDenyRules,
String? editorMode,
Object? effortLevel = _sentinel,
bool? fastMode,
Object? hooks = _sentinel,
Object? mcpServers = _sentinel,
Object? model = _sentinel,
Object? openRouterApiKey = _sentinel,
Object? outputStyle = _sentinel,
String? permissionMode,
Object? privacyLevel = _sentinel,
Object? statusLinePrompt = _sentinel,
Object? telemetry = _sentinel,
String? theme,
}) {
return LocalSettings(
advisorModel: identical(advisorModel, _sentinel) ? this.advisorModel : advisorModel as String?,
alwaysAllowRules: alwaysAllowRules ?? this.alwaysAllowRules,
alwaysAskRules: alwaysAskRules ?? this.alwaysAskRules,
alwaysDenyRules: alwaysDenyRules ?? this.alwaysDenyRules,
editorMode: editorMode ?? this.editorMode,
effortLevel: identical(effortLevel, _sentinel)
? this.effortLevel
: effortLevel as String?,
fastMode: fastMode ?? this.fastMode,
hooks: identical(hooks, _sentinel) ? this.hooks : hooks as Map<String, String>?,
mcpServers: identical(mcpServers, _sentinel) ? this.mcpServers : mcpServers as Map<String, Map<String, dynamic>>?,
model: identical(model, _sentinel) ? this.model : model as String?,
openRouterApiKey: identical(openRouterApiKey, _sentinel) ? this.openRouterApiKey : openRouterApiKey as String?,
outputStyle: identical(outputStyle, _sentinel)
? this.outputStyle
: outputStyle as String?,
permissionMode: permissionMode ?? this.permissionMode,
privacyLevel: identical(privacyLevel, _sentinel) ? this.privacyLevel : privacyLevel as String?,
statusLinePrompt: identical(statusLinePrompt, _sentinel)
? this.statusLinePrompt
: statusLinePrompt as String?,
telemetry: identical(telemetry, _sentinel) ? this.telemetry : telemetry as String?,
theme: theme ?? this.theme,
);
}
// Merges this (base) with an override layer.
// For nullable fields: override wins only if non-null.
// For list fields: override wins if non-empty.
// For non-nullable fields with defaults: override wins if different from its default.
LocalSettings mergeWith(LocalSettings? override) {
if (override == null) return this;
return LocalSettings(
advisorModel: override.advisorModel ?? advisorModel,
alwaysAllowRules: override.alwaysAllowRules.isNotEmpty ? override.alwaysAllowRules : alwaysAllowRules,
alwaysAskRules: override.alwaysAskRules.isNotEmpty ? override.alwaysAskRules : alwaysAskRules,
alwaysDenyRules: override.alwaysDenyRules.isNotEmpty ? override.alwaysDenyRules : alwaysDenyRules,
editorMode: override.editorMode != 'normal' ? override.editorMode : editorMode,
effortLevel: override.effortLevel ?? effortLevel,
fastMode: override.fastMode ? true : fastMode,
hooks: override.hooks ?? hooks,
mcpServers: override.mcpServers ?? mcpServers,
model: override.model ?? model,
openRouterApiKey: override.openRouterApiKey ?? openRouterApiKey,
outputStyle: override.outputStyle ?? outputStyle,
permissionMode: override.permissionMode != 'default' ? override.permissionMode : permissionMode,
privacyLevel: override.privacyLevel ?? privacyLevel,
statusLinePrompt: override.statusLinePrompt ?? statusLinePrompt,
telemetry: override.telemetry ?? telemetry,
theme: override.theme != 'dark' ? override.theme : theme,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'advisorModel': advisorModel,
'alwaysAllowRules': alwaysAllowRules,
'alwaysAskRules': alwaysAskRules,
'alwaysDenyRules': alwaysDenyRules,
'editorMode': editorMode,
'effortLevel': effortLevel,
'fastMode': fastMode,
'hooks': hooks,
'mcpServers': mcpServers,
'model': model,
'openRouterApiKey': openRouterApiKey,
'outputStyle': outputStyle,
'permissionMode': permissionMode,
'privacyLevel': privacyLevel,
'statusLinePrompt': statusLinePrompt,
'telemetry': telemetry,
'theme': theme,
};
}
}
class SessionState {
SessionState({required this.workingDirectory, String? effortValue})
: effortValue = effortValue,
startedAt = DateTime.now().toUtc(),
sessionId = _generateSessionId();
String? effortValue;
int commandsExecuted = 0;
bool planModeEnabled = false;
bool briefModeEnabled = false;
bool bughunterMode = false;
String? sessionColor;
// set via /advisor
String? advisorModel;
// tag toggled via /tag command
String? sessionTag;
// name set via /rename
String? sessionName;
// extra dirs added via /add-dir
final List<String> additionalDirectories = [];
final DateTime startedAt;
final String workingDirectory;
final String sessionId;
static String _generateSessionId() {
final now = DateTime.now();
final rnd = (now.millisecondsSinceEpoch % 1000000).toString().padLeft(6, '0');
return 'sess_${now.millisecondsSinceEpoch}_$rnd';
}
String get planFilePath {
return joinPath(getPlansDirectoryPath(), 'active-plan.md');
}
Future<String?> readPlan() async {
final file = File(planFilePath);
if (!await file.exists()) {
return null;
}
return file.readAsString();
}
Future<void> writePlan(String content) async {
final directory = Directory(getPlansDirectoryPath());
await directory.create(recursive: true);
await File(planFilePath).writeAsString(content);
}
}
class SettingsStore {
SettingsStore._({required this.path, required this.settings});
static const _encoder = JsonEncoder.withIndent(' ');
final String path;
LocalSettings settings;
static Future<SettingsStore> load() async {
final path = getSettingsFilePath();
final file = File(path);
if (!await file.exists()) {
final store = SettingsStore._(
path: path,
settings: const LocalSettings(),
);
await store.save();
return store;
}
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
return SettingsStore._(
path: path,
settings: LocalSettings.fromJson(decoded),
);
}
if (decoded is Map) {
return SettingsStore._(
path: path,
settings: LocalSettings.fromJson(
decoded.map((key, value) => MapEntry(key.toString(), value)),
),
);
}
} catch (_) {
// Fall back to defaults if the file is unreadable.
}
final store = SettingsStore._(path: path, settings: const LocalSettings());
await store.save();
return store;
}
Future<void> save() async {
final file = File(path);
await file.parent.create(recursive: true);
await file.writeAsString('${_encoder.convert(settings.toJson())}\n');
}
Future<void> update(
LocalSettings Function(LocalSettings current) transform,
) async {
settings = transform(settings);
await save();
}
}
const _sentinel = Object();
bool? _readBool(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is bool) {
return value;
}
return null;
}
String? _readString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is String && value.isNotEmpty) {
return value;
}
return null;
}
List<String> _readStringList(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! List) {
return const <String>[];
}
return value
.whereType<String>()
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
}
Map<String, String>? _readStringMap(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! Map) {
return null;
}
final result = <String, String>{};
for (final entry in value.entries) {
if (entry.key is String && entry.value is String) {
result[entry.key as String] = entry.value as String;
}
}
return result.isEmpty ? null : result;
}
Map<String, Map<String, dynamic>>? _readMcpServers(Map<String, dynamic> json) {
final value = json['mcpServers'];
if (value is! Map) {
return null;
}
final result = <String, Map<String, dynamic>>{};
for (final entry in value.entries) {
if (entry.key is! String) continue;
final serverCfg = entry.value;
if (serverCfg is Map) {
result[entry.key as String] = serverCfg.map(
(k, v) => MapEntry(k.toString(), v),
);
}
}
return result.isEmpty ? null : result;
}