@mseep/obsidian-agent-client 0.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/gh-setup.sh +49 -0
- package/.claude/settings.json +15 -0
- package/.claude/skills/release-notes/SKILL.md +331 -0
- package/.editorconfig +10 -0
- package/.github/FUNDING.yml +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
- package/.github/ISSUE_TEMPLATE/config.yml +11 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
- package/.github/copilot-instructions.md +45 -0
- package/.github/pull_request_template.md +32 -0
- package/.github/workflows/ci.yaml +25 -0
- package/.github/workflows/docs.yml +58 -0
- package/.github/workflows/relay_to_openclaw.yml +59 -0
- package/.github/workflows/release.yaml +45 -0
- package/.prettierignore +10 -0
- package/.prettierrc +13 -0
- package/.vscode/extensions.json +7 -0
- package/.vscode/settings.json +37 -0
- package/.zed/settings.json +42 -0
- package/AGENTS.md +330 -0
- package/ARCHITECTURE.md +390 -0
- package/CONTRIBUTING.md +216 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.ja.md +121 -0
- package/README.md +125 -0
- package/docs/.vitepress/config.mts +124 -0
- package/docs/.vitepress/theme/custom.css +111 -0
- package/docs/.vitepress/theme/index.ts +4 -0
- package/docs/agent-setup/claude-code.md +84 -0
- package/docs/agent-setup/codex.md +76 -0
- package/docs/agent-setup/custom-agents.md +67 -0
- package/docs/agent-setup/gemini-cli.md +99 -0
- package/docs/agent-setup/index.md +34 -0
- package/docs/announcements/gemini-cli-deprecation.md +73 -0
- package/docs/getting-started/index.md +78 -0
- package/docs/getting-started/quick-start.md +38 -0
- package/docs/help/faq.md +181 -0
- package/docs/help/troubleshooting.md +221 -0
- package/docs/index.md +63 -0
- package/docs/public/apple-touch-icon.png +0 -0
- package/docs/public/demo.mp4 +0 -0
- package/docs/public/favicon-16x16.png +0 -0
- package/docs/public/favicon-32x32.png +0 -0
- package/docs/public/favicon.ico +0 -0
- package/docs/public/images/editing.webp +0 -0
- package/docs/public/images/export.webp +0 -0
- package/docs/public/images/floating-chat-button.webp +0 -0
- package/docs/public/images/floating-chat-instance-menu.webp +0 -0
- package/docs/public/images/floating-chat-view.webp +0 -0
- package/docs/public/images/mode-selection.webp +0 -0
- package/docs/public/images/model-selection.webp +0 -0
- package/docs/public/images/multi-session.webp +0 -0
- package/docs/public/images/remove-image.webp +0 -0
- package/docs/public/images/ribbon-icon.webp +0 -0
- package/docs/public/images/selection-context.gif +0 -0
- package/docs/public/images/sending-images.webp +0 -0
- package/docs/public/images/sending-messages.webp +0 -0
- package/docs/public/images/session-history-button.webp +0 -0
- package/docs/public/images/slash-commands-1.webp +0 -0
- package/docs/public/images/slash-commands-2.webp +0 -0
- package/docs/public/images/switch-agent.webp +0 -0
- package/docs/public/images/switch-default-agent.webp +0 -0
- package/docs/public/images/temporary-disable.gif +0 -0
- package/docs/reference/acp-support.md +110 -0
- package/docs/usage/chat-export.md +80 -0
- package/docs/usage/commands.md +51 -0
- package/docs/usage/context-files.md +57 -0
- package/docs/usage/editing.md +69 -0
- package/docs/usage/floating-chat.md +84 -0
- package/docs/usage/index.md +97 -0
- package/docs/usage/mcp-tools.md +33 -0
- package/docs/usage/mentions.md +70 -0
- package/docs/usage/mode-selection.md +28 -0
- package/docs/usage/model-selection.md +32 -0
- package/docs/usage/multi-session.md +68 -0
- package/docs/usage/sending-images.md +64 -0
- package/docs/usage/session-history.md +91 -0
- package/docs/usage/slash-commands.md +44 -0
- package/esbuild.config.mjs +49 -0
- package/eslint.config.mjs +25 -0
- package/main.js +228 -0
- package/manifest.json +11 -0
- package/package.json +52 -0
- package/src/acp/acp-client.ts +921 -0
- package/src/acp/acp-handler.ts +252 -0
- package/src/acp/permission-handler.ts +282 -0
- package/src/acp/terminal-handler.ts +264 -0
- package/src/acp/type-converter.ts +272 -0
- package/src/hooks/useAgent.ts +250 -0
- package/src/hooks/useAgentMessages.ts +470 -0
- package/src/hooks/useAgentSession.ts +544 -0
- package/src/hooks/useChatActions.ts +400 -0
- package/src/hooks/useHistoryModal.ts +219 -0
- package/src/hooks/useSessionHistory.ts +863 -0
- package/src/hooks/useSettings.ts +19 -0
- package/src/hooks/useSuggestions.ts +342 -0
- package/src/main.ts +9 -0
- package/src/plugin.ts +1126 -0
- package/src/services/chat-exporter.ts +552 -0
- package/src/services/message-sender.ts +755 -0
- package/src/services/message-state.ts +375 -0
- package/src/services/session-helpers.ts +211 -0
- package/src/services/session-state.ts +130 -0
- package/src/services/session-storage.ts +267 -0
- package/src/services/settings-normalizer.ts +255 -0
- package/src/services/settings-service.ts +285 -0
- package/src/services/update-checker.ts +128 -0
- package/src/services/vault-service.ts +558 -0
- package/src/services/view-registry.ts +345 -0
- package/src/types/agent.ts +92 -0
- package/src/types/chat.ts +351 -0
- package/src/types/errors.ts +136 -0
- package/src/types/obsidian-internals.d.ts +14 -0
- package/src/types/session.ts +731 -0
- package/src/ui/ChangeDirectoryModal.ts +137 -0
- package/src/ui/ChatContext.ts +25 -0
- package/src/ui/ChatHeader.tsx +295 -0
- package/src/ui/ChatPanel.tsx +1162 -0
- package/src/ui/ChatView.tsx +348 -0
- package/src/ui/ErrorBanner.tsx +104 -0
- package/src/ui/FloatingButton.tsx +351 -0
- package/src/ui/FloatingChatView.tsx +531 -0
- package/src/ui/InputArea.tsx +1107 -0
- package/src/ui/InputToolbar.tsx +371 -0
- package/src/ui/MessageBubble.tsx +442 -0
- package/src/ui/MessageList.tsx +265 -0
- package/src/ui/PermissionBanner.tsx +61 -0
- package/src/ui/SessionHistoryModal.tsx +821 -0
- package/src/ui/SettingsTab.ts +1337 -0
- package/src/ui/SuggestionPopup.tsx +138 -0
- package/src/ui/TerminalBlock.tsx +107 -0
- package/src/ui/ToolCallBlock.tsx +456 -0
- package/src/ui/shared/AttachmentStrip.tsx +57 -0
- package/src/ui/shared/IconButton.tsx +55 -0
- package/src/ui/shared/MarkdownRenderer.tsx +103 -0
- package/src/ui/view-host.ts +56 -0
- package/src/utils/error-utils.ts +274 -0
- package/src/utils/logger.ts +44 -0
- package/src/utils/mention-parser.ts +129 -0
- package/src/utils/paths.ts +246 -0
- package/src/utils/platform.ts +425 -0
- package/styles.css +2322 -0
- package/tsconfig.json +18 -0
- package/version-bump.mjs +18 -0
- package/versions.json +3 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session storage for persisting session metadata and message history.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Session metadata CRUD (in plugin settings savedSessions array)
|
|
6
|
+
* - Session message file I/O (sessions/{id}.json)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Platform } from "obsidian";
|
|
10
|
+
|
|
11
|
+
import type { AgentClientPluginSettings } from "../plugin";
|
|
12
|
+
import type AgentClientPlugin from "../plugin";
|
|
13
|
+
import type { ChatMessage, MessageContent } from "../types/chat";
|
|
14
|
+
import type { SavedSessionInfo } from "../types/session";
|
|
15
|
+
import { convertWindowsPathToWsl } from "../utils/platform";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Serialized format for session message files.
|
|
23
|
+
*/
|
|
24
|
+
interface SessionMessagesFile {
|
|
25
|
+
version: number;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
agentId: string;
|
|
28
|
+
messages: Array<{
|
|
29
|
+
id: string;
|
|
30
|
+
role: "user" | "assistant";
|
|
31
|
+
content: MessageContent[];
|
|
32
|
+
timestamp: string;
|
|
33
|
+
}>;
|
|
34
|
+
savedAt: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Interface for settings access needed by SessionStorage.
|
|
39
|
+
* Subset of SettingsService to avoid circular dependency.
|
|
40
|
+
*/
|
|
41
|
+
interface SessionStorageSettingsAccess {
|
|
42
|
+
getSnapshot(): AgentClientPluginSettings;
|
|
43
|
+
updateSettings(updates: Partial<AgentClientPluginSettings>): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Implementation
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
/** Maximum number of saved sessions to keep */
|
|
51
|
+
const MAX_SAVED_SESSIONS = 50;
|
|
52
|
+
|
|
53
|
+
export class SessionStorage {
|
|
54
|
+
private plugin: AgentClientPlugin;
|
|
55
|
+
private settingsAccess: SessionStorageSettingsAccess;
|
|
56
|
+
|
|
57
|
+
/** Lock for session operations to prevent race conditions */
|
|
58
|
+
private sessionLock: Promise<void> = Promise.resolve();
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
plugin: AgentClientPlugin,
|
|
62
|
+
settingsAccess: SessionStorageSettingsAccess,
|
|
63
|
+
) {
|
|
64
|
+
this.plugin = plugin;
|
|
65
|
+
this.settingsAccess = settingsAccess;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ============================================================
|
|
69
|
+
// Session Metadata Methods
|
|
70
|
+
// ============================================================
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Save a session to local storage.
|
|
74
|
+
*
|
|
75
|
+
* Updates existing session if sessionId matches.
|
|
76
|
+
* Maintains max 50 sessions, removing oldest when exceeded.
|
|
77
|
+
*/
|
|
78
|
+
async saveSession(info: SavedSessionInfo): Promise<void> {
|
|
79
|
+
this.sessionLock = this.sessionLock.then(async () => {
|
|
80
|
+
// Convert Windows path to WSL path if in WSL mode
|
|
81
|
+
let sessionInfo = info;
|
|
82
|
+
const state = this.settingsAccess.getSnapshot();
|
|
83
|
+
if (Platform.isWin && state.windowsWslMode && info.cwd) {
|
|
84
|
+
sessionInfo = {
|
|
85
|
+
...info,
|
|
86
|
+
cwd: convertWindowsPathToWsl(info.cwd),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sessions = [...(state.savedSessions || [])];
|
|
91
|
+
|
|
92
|
+
// Find existing session by sessionId
|
|
93
|
+
const existingIndex = sessions.findIndex(
|
|
94
|
+
(s) => s.sessionId === sessionInfo.sessionId,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (existingIndex >= 0) {
|
|
98
|
+
sessions[existingIndex] = sessionInfo;
|
|
99
|
+
} else {
|
|
100
|
+
sessions.unshift(sessionInfo);
|
|
101
|
+
if (sessions.length > MAX_SAVED_SESSIONS) {
|
|
102
|
+
sessions.pop();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await this.settingsAccess.updateSettings({
|
|
107
|
+
savedSessions: sessions,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
await this.sessionLock;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get saved sessions, optionally filtered by agentId and/or cwd.
|
|
115
|
+
* Returns sessions sorted by updatedAt (newest first).
|
|
116
|
+
*/
|
|
117
|
+
getSavedSessions(agentId?: string, cwd?: string): SavedSessionInfo[] {
|
|
118
|
+
const state = this.settingsAccess.getSnapshot();
|
|
119
|
+
let sessions = state.savedSessions || [];
|
|
120
|
+
|
|
121
|
+
if (agentId) {
|
|
122
|
+
sessions = sessions.filter((s) => s.agentId === agentId);
|
|
123
|
+
}
|
|
124
|
+
if (cwd) {
|
|
125
|
+
let filterCwd = cwd;
|
|
126
|
+
if (Platform.isWin && state.windowsWslMode) {
|
|
127
|
+
filterCwd = convertWindowsPathToWsl(cwd);
|
|
128
|
+
}
|
|
129
|
+
sessions = sessions.filter((s) => s.cwd === filterCwd);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return [...sessions].sort(
|
|
133
|
+
(a, b) =>
|
|
134
|
+
new Date(b.updatedAt).getTime() -
|
|
135
|
+
new Date(a.updatedAt).getTime(),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Delete a saved session by sessionId.
|
|
141
|
+
* Also deletes the associated message history file.
|
|
142
|
+
*/
|
|
143
|
+
async deleteSession(sessionId: string): Promise<void> {
|
|
144
|
+
this.sessionLock = this.sessionLock.then(async () => {
|
|
145
|
+
const state = this.settingsAccess.getSnapshot();
|
|
146
|
+
const sessions = (state.savedSessions || []).filter(
|
|
147
|
+
(s) => s.sessionId !== sessionId,
|
|
148
|
+
);
|
|
149
|
+
await this.settingsAccess.updateSettings({
|
|
150
|
+
savedSessions: sessions,
|
|
151
|
+
});
|
|
152
|
+
await this.deleteSessionMessages(sessionId);
|
|
153
|
+
});
|
|
154
|
+
await this.sessionLock;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============================================================
|
|
158
|
+
// Session Message History Methods
|
|
159
|
+
// ============================================================
|
|
160
|
+
|
|
161
|
+
private getSessionsDir(): string {
|
|
162
|
+
return `${this.plugin.app.vault.configDir}/plugins/agent-client/sessions`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async ensureSessionsDir(): Promise<void> {
|
|
166
|
+
const adapter = this.plugin.app.vault.adapter;
|
|
167
|
+
const sessionsDir = this.getSessionsDir();
|
|
168
|
+
if (!(await adapter.exists(sessionsDir))) {
|
|
169
|
+
await adapter.mkdir(sessionsDir);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private getSessionFilePath(sessionId: string): string {
|
|
174
|
+
const safeId = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
175
|
+
return `${this.getSessionsDir()}/${safeId}.json`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Save message history for a session.
|
|
180
|
+
*/
|
|
181
|
+
async saveSessionMessages(
|
|
182
|
+
sessionId: string,
|
|
183
|
+
agentId: string,
|
|
184
|
+
messages: ChatMessage[],
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
await this.ensureSessionsDir();
|
|
187
|
+
|
|
188
|
+
const serialized = messages.map((msg) => ({
|
|
189
|
+
...msg,
|
|
190
|
+
timestamp: msg.timestamp.toISOString(),
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
const data = {
|
|
194
|
+
version: 1,
|
|
195
|
+
sessionId,
|
|
196
|
+
agentId,
|
|
197
|
+
messages: serialized,
|
|
198
|
+
savedAt: new Date().toISOString(),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const filePath = this.getSessionFilePath(sessionId);
|
|
202
|
+
await this.plugin.app.vault.adapter.write(
|
|
203
|
+
filePath,
|
|
204
|
+
JSON.stringify(data, null, 2),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Load message history for a session.
|
|
210
|
+
* Returns null if file doesn't exist or on error.
|
|
211
|
+
*/
|
|
212
|
+
async loadSessionMessages(
|
|
213
|
+
sessionId: string,
|
|
214
|
+
): Promise<ChatMessage[] | null> {
|
|
215
|
+
const filePath = this.getSessionFilePath(sessionId);
|
|
216
|
+
const adapter = this.plugin.app.vault.adapter;
|
|
217
|
+
|
|
218
|
+
if (!(await adapter.exists(filePath))) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const content = await adapter.read(filePath);
|
|
224
|
+
const data = JSON.parse(content) as SessionMessagesFile;
|
|
225
|
+
|
|
226
|
+
if (
|
|
227
|
+
typeof data.version !== "number" ||
|
|
228
|
+
!Array.isArray(data.messages)
|
|
229
|
+
) {
|
|
230
|
+
console.warn(
|
|
231
|
+
`[SessionStorage] Invalid session file structure: ${filePath}`,
|
|
232
|
+
);
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (data.version !== 1) {
|
|
237
|
+
console.warn(
|
|
238
|
+
`[SessionStorage] Unknown session file version: ${data.version}`,
|
|
239
|
+
);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return data.messages.map((msg) => ({
|
|
244
|
+
...msg,
|
|
245
|
+
timestamp: new Date(msg.timestamp),
|
|
246
|
+
}));
|
|
247
|
+
} catch (error) {
|
|
248
|
+
console.error(
|
|
249
|
+
`[SessionStorage] Failed to load session messages: ${error}`,
|
|
250
|
+
);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Delete message history file for a session.
|
|
257
|
+
* Silently succeeds if file doesn't exist.
|
|
258
|
+
*/
|
|
259
|
+
async deleteSessionMessages(sessionId: string): Promise<void> {
|
|
260
|
+
const filePath = this.getSessionFilePath(sessionId);
|
|
261
|
+
const adapter = this.plugin.app.vault.adapter;
|
|
262
|
+
|
|
263
|
+
if (await adapter.exists(filePath)) {
|
|
264
|
+
await adapter.remove(filePath);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings normalization and validation utilities.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for validating and normalizing plugin settings values.
|
|
5
|
+
* Used by plugin.ts (loadSettings) and SettingsTab.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AgentEnvVar, CustomAgentSettings } from "../plugin";
|
|
9
|
+
import type { BaseAgentSettings } from "../types/agent";
|
|
10
|
+
import type { AgentConfig } from "../acp/acp-client";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Display Settings
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
export const CHAT_FONT_SIZE_MIN = 10;
|
|
17
|
+
export const CHAT_FONT_SIZE_MAX = 30;
|
|
18
|
+
|
|
19
|
+
export const parseChatFontSize = (value: unknown): number | null => {
|
|
20
|
+
if (value === null || value === undefined) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const numericValue = (() => {
|
|
25
|
+
if (typeof value === "number") {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof value === "string") {
|
|
30
|
+
const trimmedValue = value.trim();
|
|
31
|
+
if (trimmedValue.length === 0) {
|
|
32
|
+
return Number.NaN;
|
|
33
|
+
}
|
|
34
|
+
if (!/^-?\d+$/.test(trimmedValue)) {
|
|
35
|
+
return Number.NaN;
|
|
36
|
+
}
|
|
37
|
+
return Number.parseInt(trimmedValue, 10);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return Number.NaN;
|
|
41
|
+
})();
|
|
42
|
+
|
|
43
|
+
if (!Number.isFinite(numericValue)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return Math.min(
|
|
48
|
+
CHAT_FONT_SIZE_MAX,
|
|
49
|
+
Math.max(CHAT_FONT_SIZE_MIN, Math.round(numericValue)),
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Settings Utilities
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
export const sanitizeArgs = (value: unknown): string[] => {
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return value
|
|
60
|
+
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
|
61
|
+
.filter((item) => item.length > 0);
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
return value
|
|
65
|
+
.split(/\r?\n/)
|
|
66
|
+
.map((item) => item.trim())
|
|
67
|
+
.filter((item) => item.length > 0);
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Convert stored env structures into a deduplicated list
|
|
73
|
+
export const normalizeEnvVars = (value: unknown): AgentEnvVar[] => {
|
|
74
|
+
const pairs: AgentEnvVar[] = [];
|
|
75
|
+
if (!value) {
|
|
76
|
+
return pairs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
for (const entry of value) {
|
|
81
|
+
if (entry && typeof entry === "object") {
|
|
82
|
+
// Type guard: check if entry has key and value properties
|
|
83
|
+
const entryObj = entry as Record<string, unknown>;
|
|
84
|
+
const key = "key" in entryObj ? entryObj.key : undefined;
|
|
85
|
+
const val = "value" in entryObj ? entryObj.value : undefined;
|
|
86
|
+
if (typeof key === "string" && key.trim().length > 0) {
|
|
87
|
+
pairs.push({
|
|
88
|
+
key: key.trim(),
|
|
89
|
+
value: typeof val === "string" ? val : "",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} else if (typeof value === "object") {
|
|
95
|
+
for (const [key, val] of Object.entries(
|
|
96
|
+
value as Record<string, unknown>,
|
|
97
|
+
)) {
|
|
98
|
+
if (typeof key === "string" && key.trim().length > 0) {
|
|
99
|
+
pairs.push({
|
|
100
|
+
key: key.trim(),
|
|
101
|
+
value: typeof val === "string" ? val : "",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const seen = new Set<string>();
|
|
108
|
+
return pairs.filter((pair) => {
|
|
109
|
+
if (seen.has(pair.key)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
seen.add(pair.key);
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Rebuild a custom agent entry with defaults and cleaned values
|
|
118
|
+
export const normalizeCustomAgent = (
|
|
119
|
+
agent: Record<string, unknown>,
|
|
120
|
+
): CustomAgentSettings => {
|
|
121
|
+
const rawId =
|
|
122
|
+
agent && typeof agent.id === "string" && agent.id.trim().length > 0
|
|
123
|
+
? agent.id.trim()
|
|
124
|
+
: "custom-agent";
|
|
125
|
+
const rawDisplayName =
|
|
126
|
+
agent &&
|
|
127
|
+
typeof agent.displayName === "string" &&
|
|
128
|
+
agent.displayName.trim().length > 0
|
|
129
|
+
? agent.displayName.trim()
|
|
130
|
+
: rawId;
|
|
131
|
+
return {
|
|
132
|
+
id: rawId,
|
|
133
|
+
displayName: rawDisplayName,
|
|
134
|
+
command:
|
|
135
|
+
agent &&
|
|
136
|
+
typeof agent.command === "string" &&
|
|
137
|
+
agent.command.trim().length > 0
|
|
138
|
+
? agent.command.trim()
|
|
139
|
+
: "",
|
|
140
|
+
args: sanitizeArgs(agent?.args),
|
|
141
|
+
env: normalizeEnvVars(agent?.env),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Ensure custom agent IDs are unique within the collection
|
|
146
|
+
export const ensureUniqueCustomAgentIds = (
|
|
147
|
+
agents: CustomAgentSettings[],
|
|
148
|
+
): CustomAgentSettings[] => {
|
|
149
|
+
const seen = new Set<string>();
|
|
150
|
+
return agents.map((agent) => {
|
|
151
|
+
const base =
|
|
152
|
+
agent.id && agent.id.trim().length > 0
|
|
153
|
+
? agent.id.trim()
|
|
154
|
+
: "custom-agent";
|
|
155
|
+
let candidate = base;
|
|
156
|
+
let suffix = 2;
|
|
157
|
+
while (seen.has(candidate)) {
|
|
158
|
+
candidate = `${base}-${suffix}`;
|
|
159
|
+
suffix += 1;
|
|
160
|
+
}
|
|
161
|
+
seen.add(candidate);
|
|
162
|
+
return { ...agent, id: candidate };
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Convert BaseAgentSettings to AgentConfig for process execution.
|
|
168
|
+
*
|
|
169
|
+
* Transforms the storage format (BaseAgentSettings) to the runtime format (AgentConfig)
|
|
170
|
+
* needed by AcpClient.initialize().
|
|
171
|
+
*/
|
|
172
|
+
export const toAgentConfig = (
|
|
173
|
+
settings: BaseAgentSettings,
|
|
174
|
+
workingDirectory: string,
|
|
175
|
+
): AgentConfig => {
|
|
176
|
+
// Convert AgentEnvVar[] to Record<string, string> for process.spawn()
|
|
177
|
+
const env = settings.env.reduce(
|
|
178
|
+
(acc, { key, value }) => {
|
|
179
|
+
acc[key] = value;
|
|
180
|
+
return acc;
|
|
181
|
+
},
|
|
182
|
+
{} as Record<string, string>,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
id: settings.id,
|
|
187
|
+
displayName: settings.displayName,
|
|
188
|
+
command: settings.command,
|
|
189
|
+
args: settings.args,
|
|
190
|
+
env,
|
|
191
|
+
workingDirectory,
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// Settings Loading Helpers
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
/** Extract a string value, falling back to default if not a string */
|
|
200
|
+
export function str(raw: unknown, fallback: string): string {
|
|
201
|
+
return typeof raw === "string" ? raw : fallback;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Extract a boolean value, falling back to default if not a boolean */
|
|
205
|
+
export function bool(raw: unknown, fallback: boolean): boolean {
|
|
206
|
+
return typeof raw === "boolean" ? raw : fallback;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Extract a number value with optional minimum, falling back to default */
|
|
210
|
+
export function num(raw: unknown, fallback: number, min?: number): number {
|
|
211
|
+
if (typeof raw !== "number") return fallback;
|
|
212
|
+
if (min !== undefined && raw < min) return fallback;
|
|
213
|
+
return raw;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Extract a value that must be one of the valid options */
|
|
217
|
+
export function enumVal<T extends string>(
|
|
218
|
+
raw: unknown,
|
|
219
|
+
valid: T[],
|
|
220
|
+
fallback: T,
|
|
221
|
+
): T {
|
|
222
|
+
return valid.includes(raw as T) ? (raw as T) : fallback;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Extract a plain object, or return null */
|
|
226
|
+
export function obj(raw: unknown): Record<string, unknown> | null {
|
|
227
|
+
return raw && typeof raw === "object" && !Array.isArray(raw)
|
|
228
|
+
? (raw as Record<string, unknown>)
|
|
229
|
+
: null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Extract a Record<string, string> with validated entries */
|
|
233
|
+
export function strRecord(raw: unknown): Record<string, string> {
|
|
234
|
+
const result: Record<string, string> = {};
|
|
235
|
+
const o = obj(raw);
|
|
236
|
+
if (!o) return result;
|
|
237
|
+
for (const [key, value] of Object.entries(o)) {
|
|
238
|
+
if (
|
|
239
|
+
typeof key === "string" &&
|
|
240
|
+
key.length > 0 &&
|
|
241
|
+
typeof value === "string" &&
|
|
242
|
+
value.length > 0
|
|
243
|
+
) {
|
|
244
|
+
result[key] = value;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Extract an {x, y} point, or return null if invalid */
|
|
251
|
+
export function xyPoint(raw: unknown): { x: number; y: number } | null {
|
|
252
|
+
const o = obj(raw);
|
|
253
|
+
if (!o || typeof o.x !== "number" || typeof o.y !== "number") return null;
|
|
254
|
+
return { x: o.x, y: o.y };
|
|
255
|
+
}
|