@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.
Files changed (146) hide show
  1. package/.claude/hooks/gh-setup.sh +49 -0
  2. package/.claude/settings.json +15 -0
  3. package/.claude/skills/release-notes/SKILL.md +331 -0
  4. package/.editorconfig +10 -0
  5. package/.github/FUNDING.yml +2 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
  7. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  8. package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
  9. package/.github/copilot-instructions.md +45 -0
  10. package/.github/pull_request_template.md +32 -0
  11. package/.github/workflows/ci.yaml +25 -0
  12. package/.github/workflows/docs.yml +58 -0
  13. package/.github/workflows/relay_to_openclaw.yml +59 -0
  14. package/.github/workflows/release.yaml +45 -0
  15. package/.prettierignore +10 -0
  16. package/.prettierrc +13 -0
  17. package/.vscode/extensions.json +7 -0
  18. package/.vscode/settings.json +37 -0
  19. package/.zed/settings.json +42 -0
  20. package/AGENTS.md +330 -0
  21. package/ARCHITECTURE.md +390 -0
  22. package/CONTRIBUTING.md +216 -0
  23. package/LICENSE +202 -0
  24. package/NOTICE +2 -0
  25. package/README.ja.md +121 -0
  26. package/README.md +125 -0
  27. package/docs/.vitepress/config.mts +124 -0
  28. package/docs/.vitepress/theme/custom.css +111 -0
  29. package/docs/.vitepress/theme/index.ts +4 -0
  30. package/docs/agent-setup/claude-code.md +84 -0
  31. package/docs/agent-setup/codex.md +76 -0
  32. package/docs/agent-setup/custom-agents.md +67 -0
  33. package/docs/agent-setup/gemini-cli.md +99 -0
  34. package/docs/agent-setup/index.md +34 -0
  35. package/docs/announcements/gemini-cli-deprecation.md +73 -0
  36. package/docs/getting-started/index.md +78 -0
  37. package/docs/getting-started/quick-start.md +38 -0
  38. package/docs/help/faq.md +181 -0
  39. package/docs/help/troubleshooting.md +221 -0
  40. package/docs/index.md +63 -0
  41. package/docs/public/apple-touch-icon.png +0 -0
  42. package/docs/public/demo.mp4 +0 -0
  43. package/docs/public/favicon-16x16.png +0 -0
  44. package/docs/public/favicon-32x32.png +0 -0
  45. package/docs/public/favicon.ico +0 -0
  46. package/docs/public/images/editing.webp +0 -0
  47. package/docs/public/images/export.webp +0 -0
  48. package/docs/public/images/floating-chat-button.webp +0 -0
  49. package/docs/public/images/floating-chat-instance-menu.webp +0 -0
  50. package/docs/public/images/floating-chat-view.webp +0 -0
  51. package/docs/public/images/mode-selection.webp +0 -0
  52. package/docs/public/images/model-selection.webp +0 -0
  53. package/docs/public/images/multi-session.webp +0 -0
  54. package/docs/public/images/remove-image.webp +0 -0
  55. package/docs/public/images/ribbon-icon.webp +0 -0
  56. package/docs/public/images/selection-context.gif +0 -0
  57. package/docs/public/images/sending-images.webp +0 -0
  58. package/docs/public/images/sending-messages.webp +0 -0
  59. package/docs/public/images/session-history-button.webp +0 -0
  60. package/docs/public/images/slash-commands-1.webp +0 -0
  61. package/docs/public/images/slash-commands-2.webp +0 -0
  62. package/docs/public/images/switch-agent.webp +0 -0
  63. package/docs/public/images/switch-default-agent.webp +0 -0
  64. package/docs/public/images/temporary-disable.gif +0 -0
  65. package/docs/reference/acp-support.md +110 -0
  66. package/docs/usage/chat-export.md +80 -0
  67. package/docs/usage/commands.md +51 -0
  68. package/docs/usage/context-files.md +57 -0
  69. package/docs/usage/editing.md +69 -0
  70. package/docs/usage/floating-chat.md +84 -0
  71. package/docs/usage/index.md +97 -0
  72. package/docs/usage/mcp-tools.md +33 -0
  73. package/docs/usage/mentions.md +70 -0
  74. package/docs/usage/mode-selection.md +28 -0
  75. package/docs/usage/model-selection.md +32 -0
  76. package/docs/usage/multi-session.md +68 -0
  77. package/docs/usage/sending-images.md +64 -0
  78. package/docs/usage/session-history.md +91 -0
  79. package/docs/usage/slash-commands.md +44 -0
  80. package/esbuild.config.mjs +49 -0
  81. package/eslint.config.mjs +25 -0
  82. package/main.js +228 -0
  83. package/manifest.json +11 -0
  84. package/package.json +52 -0
  85. package/src/acp/acp-client.ts +921 -0
  86. package/src/acp/acp-handler.ts +252 -0
  87. package/src/acp/permission-handler.ts +282 -0
  88. package/src/acp/terminal-handler.ts +264 -0
  89. package/src/acp/type-converter.ts +272 -0
  90. package/src/hooks/useAgent.ts +250 -0
  91. package/src/hooks/useAgentMessages.ts +470 -0
  92. package/src/hooks/useAgentSession.ts +544 -0
  93. package/src/hooks/useChatActions.ts +400 -0
  94. package/src/hooks/useHistoryModal.ts +219 -0
  95. package/src/hooks/useSessionHistory.ts +863 -0
  96. package/src/hooks/useSettings.ts +19 -0
  97. package/src/hooks/useSuggestions.ts +342 -0
  98. package/src/main.ts +9 -0
  99. package/src/plugin.ts +1126 -0
  100. package/src/services/chat-exporter.ts +552 -0
  101. package/src/services/message-sender.ts +755 -0
  102. package/src/services/message-state.ts +375 -0
  103. package/src/services/session-helpers.ts +211 -0
  104. package/src/services/session-state.ts +130 -0
  105. package/src/services/session-storage.ts +267 -0
  106. package/src/services/settings-normalizer.ts +255 -0
  107. package/src/services/settings-service.ts +285 -0
  108. package/src/services/update-checker.ts +128 -0
  109. package/src/services/vault-service.ts +558 -0
  110. package/src/services/view-registry.ts +345 -0
  111. package/src/types/agent.ts +92 -0
  112. package/src/types/chat.ts +351 -0
  113. package/src/types/errors.ts +136 -0
  114. package/src/types/obsidian-internals.d.ts +14 -0
  115. package/src/types/session.ts +731 -0
  116. package/src/ui/ChangeDirectoryModal.ts +137 -0
  117. package/src/ui/ChatContext.ts +25 -0
  118. package/src/ui/ChatHeader.tsx +295 -0
  119. package/src/ui/ChatPanel.tsx +1162 -0
  120. package/src/ui/ChatView.tsx +348 -0
  121. package/src/ui/ErrorBanner.tsx +104 -0
  122. package/src/ui/FloatingButton.tsx +351 -0
  123. package/src/ui/FloatingChatView.tsx +531 -0
  124. package/src/ui/InputArea.tsx +1107 -0
  125. package/src/ui/InputToolbar.tsx +371 -0
  126. package/src/ui/MessageBubble.tsx +442 -0
  127. package/src/ui/MessageList.tsx +265 -0
  128. package/src/ui/PermissionBanner.tsx +61 -0
  129. package/src/ui/SessionHistoryModal.tsx +821 -0
  130. package/src/ui/SettingsTab.ts +1337 -0
  131. package/src/ui/SuggestionPopup.tsx +138 -0
  132. package/src/ui/TerminalBlock.tsx +107 -0
  133. package/src/ui/ToolCallBlock.tsx +456 -0
  134. package/src/ui/shared/AttachmentStrip.tsx +57 -0
  135. package/src/ui/shared/IconButton.tsx +55 -0
  136. package/src/ui/shared/MarkdownRenderer.tsx +103 -0
  137. package/src/ui/view-host.ts +56 -0
  138. package/src/utils/error-utils.ts +274 -0
  139. package/src/utils/logger.ts +44 -0
  140. package/src/utils/mention-parser.ts +129 -0
  141. package/src/utils/paths.ts +246 -0
  142. package/src/utils/platform.ts +425 -0
  143. package/styles.css +2322 -0
  144. package/tsconfig.json +18 -0
  145. package/version-bump.mjs +18 -0
  146. 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
+ }