vectorforce 0.0.0-stage → 0.2.0

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 (37) hide show
  1. package/.agents/agents/AGENTS.md +12 -0
  2. package/.agents/agents/vectorforce-architect.md +35 -0
  3. package/.agents/agents/vectorforce-auditor.md +25 -0
  4. package/.agents/agents/vectorforce-samples-builder.md +30 -0
  5. package/.agents/mcp_config.json +11 -0
  6. package/.codex/config.toml +5 -0
  7. package/AGENTS.md +115 -0
  8. package/LICENSE +21 -0
  9. package/README.md +198 -2
  10. package/bin/vf.js +1159 -0
  11. package/codex.json +14 -0
  12. package/commandcode.json +11 -0
  13. package/musecode.json +14 -0
  14. package/package.json +40 -4
  15. package/src/core/errors.js +45 -0
  16. package/src/core/hash.js +56 -0
  17. package/src/core/types.js +194 -0
  18. package/src/index.js +17 -0
  19. package/src/mcp/harness-config.js +546 -0
  20. package/src/mcp/mcp-server.js +507 -0
  21. package/src/operations/briefing-service.js +313 -0
  22. package/src/operations/checkpoint-service.js +120 -0
  23. package/src/operations/handoff-service.js +343 -0
  24. package/src/operations/project-service.js +132 -0
  25. package/src/operations/reasoning-service.js +356 -0
  26. package/src/policy/authorizer.js +147 -0
  27. package/src/providers/code-index-provider.js +285 -0
  28. package/src/providers/document-provider.js +105 -0
  29. package/src/providers/git-provider.js +98 -0
  30. package/src/providers/llm-provider.js +425 -0
  31. package/src/storage/sqlite-store.js +387 -0
  32. package/src/studio/server.js +764 -0
  33. package/studio/dist/assets/index-C0_eVpV1.js +9 -0
  34. package/studio/dist/assets/index-CxTK-2ty.css +2 -0
  35. package/studio/dist/favicon.svg +1 -0
  36. package/studio/dist/icons.svg +24 -0
  37. package/studio/dist/index.html +17 -0
@@ -0,0 +1,425 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ErrorCode, VectorForceError } from '../core/errors.js';
4
+
5
+ export const DEFAULT_OLLAMA_BASE_URL = 'http://127.0.0.1:11434';
6
+ export const DEFAULT_OLLAMA_MODEL = 'llama3.1:8b';
7
+ export const DEFAULT_CLOUD_MODEL = 'llama-3.3-70b-versatile';
8
+ export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
9
+ export const DEFAULT_OPENROUTER_MODEL = 'deepseek/deepseek-v4.1-flash-1017';
10
+ export const DEFAULT_MUSE_BASE_URL = 'https://api.meta.ai/v1';
11
+ export const DEFAULT_MUSE_MODEL = 'muse-spark-1.3';
12
+ export const DEFAULT_FIREWORKS_BASE_URL = 'https://api.fireworks.ai/inference/v1';
13
+ export const DEFAULT_FIREWORKS_MODEL = 'accounts/fireworks/models/muse-spark-open';
14
+ export const DEFAULT_TIMEOUT_MS = 60000;
15
+
16
+ function trimTrailingSlash(url) {
17
+ return String(url).replace(/\/+$/, '');
18
+ }
19
+
20
+ /**
21
+ * Read configuration from .vectorforce/config.json or .env in the workspace if present.
22
+ * Zero external dependencies.
23
+ * @param {string|null} workspacePath
24
+ * @returns {Record<string, string>}
25
+ */
26
+ export function readStoredConfig(workspacePath) {
27
+ if (!workspacePath || typeof workspacePath !== 'string') return {};
28
+ const candidates = [
29
+ join(workspacePath, '.vectorforce', 'config.json'),
30
+ join(workspacePath, '.env')
31
+ ];
32
+
33
+ const merged = {};
34
+ for (const p of candidates) {
35
+ try {
36
+ if (existsSync(p)) {
37
+ if (p.endsWith('.json')) {
38
+ const data = JSON.parse(readFileSync(p, 'utf8'));
39
+ if (data && typeof data === 'object') {
40
+ if (data.env && typeof data.env === 'object') {
41
+ Object.assign(merged, data.env);
42
+ }
43
+ if (data.llm && typeof data.llm === 'object') {
44
+ if (data.llm.provider) merged.VF_LLM_PROVIDER = data.llm.provider;
45
+ if (data.llm.provider === 'muse' || data.llm.provider === 'muse-code') {
46
+ if (data.llm.apiKey) {
47
+ merged.MODEL_API_KEY = data.llm.apiKey;
48
+ merged.MUSE_API_KEY = data.llm.apiKey;
49
+ merged.VF_MUSE_API_KEY = data.llm.apiKey;
50
+ merged.VF_LLM_API_KEY = data.llm.apiKey;
51
+ }
52
+ if (data.llm.model) merged.VF_MUSE_MODEL = data.llm.model;
53
+ if (data.llm.baseUrl) merged.VF_MUSE_BASE_URL = data.llm.baseUrl;
54
+ } else {
55
+ if (data.llm.apiKey) {
56
+ merged.OPENROUTER_API_KEY = data.llm.apiKey;
57
+ merged.VF_OPENROUTER_API_KEY = data.llm.apiKey;
58
+ merged.VF_LLM_API_KEY = data.llm.apiKey;
59
+ }
60
+ if (data.llm.model) merged.VF_OPENROUTER_MODEL = data.llm.model;
61
+ if (data.llm.baseUrl) merged.VF_OPENROUTER_BASE_URL = data.llm.baseUrl;
62
+ }
63
+ }
64
+ for (const [k, v] of Object.entries(data)) {
65
+ if (typeof v === 'string') merged[k] = v;
66
+ }
67
+ }
68
+ } else if (p.endsWith('.env')) {
69
+ const lines = readFileSync(p, 'utf8').split('\n');
70
+ for (const line of lines) {
71
+ const trimmed = line.trim();
72
+ if (!trimmed || trimmed.startsWith('#')) continue;
73
+ const eq = trimmed.indexOf('=');
74
+ if (eq > 0) {
75
+ const k = trimmed.slice(0, eq).trim();
76
+ let v = trimmed.slice(eq + 1).trim();
77
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
78
+ v = v.slice(1, -1);
79
+ }
80
+ merged[k] = v;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ } catch {}
86
+ }
87
+ return merged;
88
+ }
89
+
90
+ /**
91
+ * Resolve LLM provider configuration from explicit overrides, environment, then defaults.
92
+ * Supports OpenRouter and OpenAI/Codex (OpenAI-compatible /chat/completions), the Meta
93
+ * Muse API (Responses API at api.meta.ai/v1/responses), and a local Ollama runtime.
94
+ *
95
+ * @param {Record<string, string|undefined>} [env]
96
+ * @param {object} [overrides]
97
+ * @param {string} [workspacePath]
98
+ * @returns {{ provider: string, baseUrl: string, apiKey: string|null, model: string, timeoutMs: number }}
99
+ */
100
+ export function resolveLlmConfig(env = process.env, overrides = {}, workspacePath = null) {
101
+ const stored = readStoredConfig(workspacePath || (env && env.VF_WORKSPACE ? env.VF_WORKSPACE : null));
102
+ const mergedEnv = { ...stored, ...(env || {}) };
103
+
104
+ const provider = (
105
+ overrides.provider ||
106
+ mergedEnv.VF_LLM_PROVIDER ||
107
+ (mergedEnv.FIREWORKS_API_KEY || mergedEnv.VF_FIREWORKS_API_KEY ? 'fireworks' :
108
+ (mergedEnv.OPENROUTER_API_KEY || mergedEnv.VF_OPENROUTER_API_KEY ? 'openrouter' :
109
+ (mergedEnv.VF_CODEX_API_KEY ? 'codex' :
110
+ (mergedEnv.MODEL_API_KEY || mergedEnv.MUSE_API_KEY || mergedEnv.VF_MUSE_API_KEY ? 'muse' :
111
+ (mergedEnv.VF_LLM_API_KEY ? 'openai' : 'ollama')))))
112
+ ).toLowerCase();
113
+ const timeoutMs = overrides.timeoutMs ?? (mergedEnv.VF_LLM_TIMEOUT_MS ? Number(mergedEnv.VF_LLM_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS);
114
+
115
+ if (provider === 'ollama') {
116
+ const base = overrides.baseUrl || mergedEnv.VF_OLLAMA_BASE_URL || DEFAULT_OLLAMA_BASE_URL;
117
+ return {
118
+ provider: 'ollama',
119
+ baseUrl: `${trimTrailingSlash(base)}/v1`,
120
+ apiKey: null,
121
+ model: overrides.model || mergedEnv.VF_OLLAMA_MODEL || DEFAULT_OLLAMA_MODEL,
122
+ timeoutMs
123
+ };
124
+ }
125
+
126
+ if (provider === 'fireworks') {
127
+ const baseUrl = trimTrailingSlash(overrides.baseUrl || mergedEnv.VF_FIREWORKS_BASE_URL || mergedEnv.FIREWORKS_BASE_URL || mergedEnv.VF_LLM_BASE_URL || DEFAULT_FIREWORKS_BASE_URL);
128
+ const apiKey = overrides.apiKey ?? mergedEnv.FIREWORKS_API_KEY ?? mergedEnv.VF_FIREWORKS_API_KEY ?? mergedEnv.VF_LLM_API_KEY ?? null;
129
+ const model = overrides.model || mergedEnv.VF_FIREWORKS_MODEL || mergedEnv.FIREWORKS_MODEL || mergedEnv.VF_LLM_MODEL || DEFAULT_FIREWORKS_MODEL;
130
+ if (!apiKey) {
131
+ throw new VectorForceError(ErrorCode.LLM_CONFIG_INVALID, 'Fireworks provider requires FIREWORKS_API_KEY or VF_FIREWORKS_API_KEY');
132
+ }
133
+ return { provider: 'fireworks', baseUrl, apiKey, model, timeoutMs };
134
+ }
135
+
136
+ if (provider === 'openrouter') {
137
+ const baseUrl = trimTrailingSlash(overrides.baseUrl || mergedEnv.VF_OPENROUTER_BASE_URL || mergedEnv.OPENROUTER_BASE_URL || mergedEnv.VF_LLM_BASE_URL || DEFAULT_OPENROUTER_BASE_URL);
138
+ const apiKey = overrides.apiKey ?? mergedEnv.OPENROUTER_API_KEY ?? mergedEnv.VF_OPENROUTER_API_KEY ?? mergedEnv.VF_LLM_API_KEY ?? null;
139
+ const model = overrides.model || mergedEnv.VF_OPENROUTER_MODEL || mergedEnv.OPENROUTER_MODEL || mergedEnv.VF_LLM_MODEL || DEFAULT_OPENROUTER_MODEL;
140
+ if (!apiKey) {
141
+ throw new VectorForceError(ErrorCode.LLM_CONFIG_INVALID, 'OpenRouter provider requires OPENROUTER_API_KEY or VF_OPENROUTER_API_KEY');
142
+ }
143
+ return { provider: 'openrouter', baseUrl, apiKey, model, timeoutMs };
144
+ }
145
+
146
+ if (provider === 'muse' || provider === 'muse-code') {
147
+ const baseUrl = trimTrailingSlash(overrides.baseUrl || mergedEnv.VF_MUSE_BASE_URL || mergedEnv.MUSE_BASE_URL || mergedEnv.VF_LLM_BASE_URL || DEFAULT_MUSE_BASE_URL);
148
+ const apiKey = overrides.apiKey ?? mergedEnv.MODEL_API_KEY ?? mergedEnv.MUSE_API_KEY ?? mergedEnv.VF_MUSE_API_KEY ?? mergedEnv.VF_LLM_API_KEY ?? null;
149
+ const model = overrides.model || mergedEnv.VF_MUSE_MODEL || mergedEnv.MUSE_MODEL || mergedEnv.VF_LLM_MODEL || DEFAULT_MUSE_MODEL;
150
+ if (!apiKey) {
151
+ throw new VectorForceError(ErrorCode.LLM_CONFIG_INVALID, 'Muse provider requires MODEL_API_KEY (or MUSE_API_KEY / VF_MUSE_API_KEY)');
152
+ }
153
+ return { provider: 'muse', baseUrl, apiKey, model, timeoutMs };
154
+ }
155
+
156
+ if (provider === 'openai' || provider === 'codex' || provider === 'anthropic' || provider === 'byok') {
157
+ const defaultBase = provider === 'anthropic' ? 'https://api.anthropic.com/v1' : '';
158
+ const baseUrl = trimTrailingSlash(overrides.baseUrl || mergedEnv.VF_CODEX_BASE_URL || mergedEnv.OPENAI_BASE_URL || mergedEnv.ANTHROPIC_BASE_URL || mergedEnv.VF_LLM_BASE_URL || defaultBase);
159
+ const apiKey = overrides.apiKey ?? mergedEnv.VF_CODEX_API_KEY ?? mergedEnv.OPENAI_API_KEY ?? mergedEnv.ANTHROPIC_API_KEY ?? mergedEnv.VF_LLM_API_KEY ?? null;
160
+ const defaultModel = provider === 'anthropic' ? 'claude-sonnet-4-20250514' : DEFAULT_CLOUD_MODEL;
161
+ const model = overrides.model || mergedEnv.VF_CODEX_MODEL || mergedEnv.VF_LLM_MODEL || defaultModel;
162
+ if (!baseUrl) {
163
+ throw new VectorForceError(
164
+ ErrorCode.LLM_CONFIG_INVALID,
165
+ `Cloud LLM provider (${provider}) requires VF_LLM_BASE_URL or VF_CODEX_BASE_URL (an OpenAI-compatible endpoint)`
166
+ );
167
+ }
168
+ if (!apiKey) {
169
+ throw new VectorForceError(ErrorCode.LLM_CONFIG_INVALID, `Cloud LLM provider (${provider}) requires VF_LLM_API_KEY or VF_CODEX_API_KEY`);
170
+ }
171
+ return { provider, baseUrl, apiKey, model, timeoutMs };
172
+ }
173
+
174
+ throw new VectorForceError(
175
+ ErrorCode.LLM_CONFIG_INVALID,
176
+ `Unknown LLM provider '${provider}'. Use 'openrouter', 'muse', 'codex', 'openai', 'anthropic', 'byok' (cloud) or 'ollama' (local).`
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Minimal OpenAI-compatible chat client built on the global fetch.
182
+ * Zero runtime dependencies.
183
+ */
184
+ export class LlmProvider {
185
+ /**
186
+ * @param {{ provider: string, baseUrl: string, apiKey?: string|null, model: string, timeoutMs?: number }} config
187
+ */
188
+ constructor(config) {
189
+ if (!config || typeof config !== 'object') {
190
+ throw new VectorForceError(ErrorCode.LLM_CONFIG_INVALID, 'LlmProvider requires a config object');
191
+ }
192
+ const { provider, baseUrl, apiKey = null, model, timeoutMs = DEFAULT_TIMEOUT_MS } = config;
193
+ if (!provider || !baseUrl || !model) {
194
+ throw new VectorForceError(
195
+ ErrorCode.LLM_CONFIG_INVALID,
196
+ 'LlmProvider config requires provider, baseUrl, and model'
197
+ );
198
+ }
199
+ this.provider = provider;
200
+ this.baseUrl = trimTrailingSlash(baseUrl);
201
+ this.apiKey = apiKey;
202
+ this.model = model;
203
+ this.timeoutMs = timeoutMs;
204
+ }
205
+
206
+ _headers() {
207
+ const headers = { 'Content-Type': 'application/json' };
208
+ if (this.apiKey) {
209
+ headers.Authorization = `Bearer ${this.apiKey}`;
210
+ }
211
+ if (this.provider === 'openrouter') {
212
+ headers['HTTP-Referer'] = 'https://github.com/vectorforce/vectorforce';
213
+ headers['X-Title'] = 'VectorForce';
214
+ }
215
+ if (this.provider === 'muse' || this.provider === 'muse-code') {
216
+ headers.Accept = 'text/event-stream';
217
+ }
218
+ return headers;
219
+ }
220
+
221
+ get _isMuse() {
222
+ return this.provider === 'muse' || this.provider === 'muse-code';
223
+ }
224
+
225
+ /**
226
+ * Extract assistant text and usage from a Meta Responses API payload
227
+ * (either a parsed JSON body or a completed SSE response object).
228
+ * @param {object} payload
229
+ * @returns {{ text: string|null, usage: object|null, model: string|null }}
230
+ */
231
+ static _museResult(payload) {
232
+ if (!payload || typeof payload !== 'object') return { text: null, usage: null, model: null };
233
+ let text = typeof payload.output_text === 'string' ? payload.output_text : null;
234
+ if ((typeof text !== 'string' || text.trim().length === 0) && Array.isArray(payload.output)) {
235
+ const parts = [];
236
+ for (const item of payload.output) {
237
+ if (item && Array.isArray(item.content)) {
238
+ for (const c of item.content) {
239
+ if (c && (c.type === 'output_text' || c.type === 'text') && typeof c.text === 'string') parts.push(c.text);
240
+ }
241
+ }
242
+ }
243
+ if (parts.length) text = parts.join('');
244
+ }
245
+ return { text, usage: payload.usage || null, model: payload.model || null };
246
+ }
247
+
248
+ /**
249
+ * Parse a Responses API SSE stream into assistant text plus the final response object.
250
+ * @param {string} raw
251
+ * @returns {{ text: string|null, usage: object|null, model: string|null }}
252
+ */
253
+ static _parseMuseSse(raw) {
254
+ let deltaText = '';
255
+ let completed = null;
256
+ for (const line of String(raw).split('\n')) {
257
+ const trimmed = line.trim();
258
+ if (!trimmed.startsWith('data:')) continue;
259
+ const data = trimmed.slice(5).trim();
260
+ if (!data || data === '[DONE]') continue;
261
+ let event;
262
+ try { event = JSON.parse(data); } catch { continue; }
263
+ if (event.type === 'response.output_text.delta' && typeof event.delta === 'string') {
264
+ deltaText += event.delta;
265
+ } else if (event.type === 'response.completed' && event.response) {
266
+ completed = event.response;
267
+ }
268
+ }
269
+ const fromCompleted = LlmProvider._museResult(completed);
270
+ return {
271
+ text: deltaText.length ? deltaText : fromCompleted.text,
272
+ usage: fromCompleted.usage,
273
+ model: fromCompleted.model
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Run a single chat completion.
279
+ * @param {object} params
280
+ * @param {string} params.system
281
+ * @param {string} params.user
282
+ * @param {number} [params.temperature=0.2]
283
+ * @param {number} [params.maxTokens=4000]
284
+ * @param {string} [params.model] Override the configured model id
285
+ * @param {AbortSignal} [params.signal]
286
+ * @returns {Promise<{ text: string, model: string, provider: string, usage: object|null }>}
287
+ */
288
+ async complete({ system, user, temperature = 0.2, maxTokens = 4000, model, signal } = {}) {
289
+ if (typeof system !== 'string' || typeof user !== 'string') {
290
+ throw new VectorForceError(ErrorCode.LLM_CONFIG_INVALID, 'complete() requires string system and user prompts');
291
+ }
292
+ if (typeof fetch !== 'function') {
293
+ throw new VectorForceError(ErrorCode.LLM_UNAVAILABLE, 'Global fetch is unavailable in this Node runtime');
294
+ }
295
+
296
+ const activeModel = model || this.model;
297
+
298
+ const controller = new AbortController();
299
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
300
+ const onExternalAbort = () => controller.abort();
301
+ if (signal) {
302
+ if (signal.aborted) controller.abort();
303
+ else signal.addEventListener('abort', onExternalAbort, { once: true });
304
+ }
305
+
306
+ const isMuse = this._isMuse;
307
+ const url = isMuse ? `${this.baseUrl}/responses` : `${this.baseUrl}/chat/completions`;
308
+ const requestBody = isMuse
309
+ ? {
310
+ model: activeModel,
311
+ input: [
312
+ { role: 'system', content: system },
313
+ { role: 'user', content: user }
314
+ ],
315
+ stream: true,
316
+ temperature,
317
+ max_output_tokens: maxTokens,
318
+ top_p: 1,
319
+ reasoning: { effort: 'medium' }
320
+ }
321
+ : {
322
+ model: activeModel,
323
+ messages: [
324
+ { role: 'system', content: system },
325
+ { role: 'user', content: user }
326
+ ],
327
+ temperature,
328
+ max_tokens: maxTokens
329
+ };
330
+
331
+ let response;
332
+ try {
333
+ response = await fetch(url, {
334
+ method: 'POST',
335
+ headers: this._headers(),
336
+ body: JSON.stringify(requestBody),
337
+ signal: controller.signal
338
+ });
339
+ } catch (err) {
340
+ if (err && err.name === 'AbortError') {
341
+ throw new VectorForceError(
342
+ ErrorCode.LLM_REQUEST_FAILED,
343
+ `LLM request timed out after ${this.timeoutMs}ms`
344
+ );
345
+ }
346
+ const cause = err && err.cause ? ` [cause: ${err.cause.code || err.cause.message || err.cause}]` : '';
347
+ throw new VectorForceError(
348
+ ErrorCode.LLM_UNAVAILABLE,
349
+ `Cannot reach LLM provider at ${this.baseUrl}: ${err && err.message ? err.message : err}${cause}`
350
+ );
351
+ } finally {
352
+ clearTimeout(timer);
353
+ if (signal) signal.removeEventListener('abort', onExternalAbort);
354
+ }
355
+
356
+ if (!response.ok) {
357
+ let detail = '';
358
+ try {
359
+ detail = await response.text();
360
+ } catch {
361
+ /* ignore body read errors */
362
+ }
363
+ throw new VectorForceError(
364
+ ErrorCode.LLM_REQUEST_FAILED,
365
+ `LLM provider returned ${response.status} ${response.statusText}${detail ? `: ${detail.slice(0, 300)}` : ''}`
366
+ );
367
+ }
368
+
369
+ if (isMuse) {
370
+ let raw;
371
+ try {
372
+ raw = await response.text();
373
+ } catch (err) {
374
+ throw new VectorForceError(
375
+ ErrorCode.LLM_RESPONSE_INVALID,
376
+ `Muse provider returned an unreadable response: ${err && err.message ? err.message : err}`
377
+ );
378
+ }
379
+ let result = LlmProvider._parseMuseSse(raw);
380
+ if (typeof result.text !== 'string' || result.text.trim().length === 0) {
381
+ try {
382
+ result = LlmProvider._museResult(JSON.parse(raw));
383
+ } catch {
384
+ /* fall through to the invalid-response error below */
385
+ }
386
+ }
387
+ if (typeof result.text !== 'string' || result.text.trim().length === 0) {
388
+ throw new VectorForceError(ErrorCode.LLM_RESPONSE_INVALID, 'Muse response missing output text');
389
+ }
390
+ return {
391
+ text: result.text.trim(),
392
+ model: result.model || activeModel,
393
+ provider: this.provider,
394
+ usage: result.usage || null
395
+ };
396
+ }
397
+
398
+ let payload;
399
+ try {
400
+ payload = await response.json();
401
+ } catch (err) {
402
+ throw new VectorForceError(
403
+ ErrorCode.LLM_RESPONSE_INVALID,
404
+ `LLM provider returned a non-JSON response: ${err && err.message ? err.message : err}`
405
+ );
406
+ }
407
+
408
+ let text = payload && payload.choices && payload.choices[0] && payload.choices[0].message
409
+ ? payload.choices[0].message.content
410
+ : null;
411
+ if ((typeof text !== 'string' || text.trim().length === 0) && payload?.choices?.[0]?.message?.reasoning) {
412
+ text = payload.choices[0].message.reasoning;
413
+ }
414
+ if (typeof text !== 'string' || text.trim().length === 0) {
415
+ throw new VectorForceError(ErrorCode.LLM_RESPONSE_INVALID, 'LLM response missing choices[0].message.content');
416
+ }
417
+
418
+ return {
419
+ text: text.trim(),
420
+ model: payload.model || activeModel,
421
+ provider: this.provider,
422
+ usage: payload.usage || null
423
+ };
424
+ }
425
+ }