explorbot 0.1.32-beta.1 → 0.2.1
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/README.md +35 -19
- package/bin/explorbot-cli.ts +33 -10
- package/boat/api-tester/src/config.ts +45 -3
- package/boat/doc-collector/src/ai/documentarian.ts +26 -28
- package/boat/doc-collector/src/ai/tools.ts +74 -16
- package/boat/doc-collector/src/cli.ts +1 -6
- package/boat/doc-collector/src/docbot.ts +30 -9
- package/boat/doc-collector/src/docs-renderer.ts +19 -24
- package/boat/doc-collector/src/screenshots.ts +34 -2
- package/boat/doc-collector/src/state-diagram.ts +281 -0
- package/dist/bin/explorbot-cli.js +29 -9
- package/dist/boat/api-tester/src/config.js +43 -4
- package/dist/boat/doc-collector/src/ai/documentarian.js +19 -7
- package/dist/boat/doc-collector/src/ai/tools.js +53 -16
- package/dist/boat/doc-collector/src/cli.js +1 -5
- package/dist/boat/doc-collector/src/docbot.js +27 -8
- package/dist/boat/doc-collector/src/docs-renderer.js +18 -3
- package/dist/boat/doc-collector/src/screenshots.js +30 -1
- package/dist/boat/doc-collector/src/state-diagram.js +200 -0
- package/dist/models.json +30 -0
- package/dist/package.json +23 -3
- package/dist/src/action-result.d.ts +8 -10
- package/dist/src/action-result.js +72 -164
- package/dist/src/action.d.ts +13 -12
- package/dist/src/action.js +37 -113
- package/dist/src/ai/agent.d.ts +17 -0
- package/dist/src/ai/captain/idle-mode.js +1 -1
- package/dist/src/ai/captain/test-mode.js +1 -1
- package/dist/src/ai/captain/web-mode.js +18 -29
- package/dist/src/ai/captain.d.ts +1 -3
- package/dist/src/ai/captain.js +17 -45
- package/dist/src/ai/conversation.d.ts +2 -5
- package/dist/src/ai/conversation.js +9 -28
- package/dist/src/ai/driller.d.ts +2 -17
- package/dist/src/ai/driller.js +26 -50
- package/dist/src/ai/experience-compactor.d.ts +2 -2
- package/dist/src/ai/experience-compactor.js +16 -33
- package/dist/src/ai/fisherman-tools.js +1 -1
- package/dist/src/ai/fisherman.d.ts +0 -1
- package/dist/src/ai/fisherman.js +0 -3
- package/dist/src/ai/historian/codeceptjs.d.ts +2 -0
- package/dist/src/ai/historian/codeceptjs.js +4 -3
- package/dist/src/ai/historian/playwright.d.ts +4 -1
- package/dist/src/ai/historian/playwright.js +4 -3
- package/dist/src/ai/historian/screencast.d.ts +2 -1
- package/dist/src/ai/historian/screencast.js +2 -2
- package/dist/src/ai/historian/utils.d.ts +0 -1
- package/dist/src/ai/historian/utils.js +0 -1
- package/dist/src/ai/historian.d.ts +10 -8
- package/dist/src/ai/historian.js +1 -5
- package/dist/src/ai/navigator.d.ts +8 -6
- package/dist/src/ai/navigator.js +34 -65
- package/dist/src/ai/pilot.d.ts +13 -8
- package/dist/src/ai/pilot.js +53 -59
- package/dist/src/ai/planner.d.ts +5 -4
- package/dist/src/ai/planner.js +66 -52
- package/dist/src/ai/provider.d.ts +4 -1
- package/dist/src/ai/provider.js +77 -137
- package/dist/src/ai/quartermaster.d.ts +22 -22
- package/dist/src/ai/quartermaster.js +8 -14
- package/dist/src/ai/rerunner.d.ts +3 -13
- package/dist/src/ai/rerunner.js +23 -44
- package/dist/src/ai/researcher/cache.js +11 -18
- package/dist/src/ai/researcher/coordinates.js +4 -3
- package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
- package/dist/src/ai/researcher/deep-analysis.js +8 -11
- package/dist/src/ai/researcher/locators.d.ts +9 -2
- package/dist/src/ai/researcher/locators.js +65 -8
- package/dist/src/ai/researcher/parser.js +1 -1
- package/dist/src/ai/researcher/research-result.d.ts +0 -1
- package/dist/src/ai/researcher/research-result.js +0 -19
- package/dist/src/ai/researcher/sections.d.ts +2 -0
- package/dist/src/ai/researcher/sections.js +7 -3
- package/dist/src/ai/researcher.js +40 -106
- package/dist/src/ai/rules.d.ts +1 -0
- package/dist/src/ai/rules.js +22 -15
- package/dist/src/ai/session-analyst.js +8 -5
- package/dist/src/ai/task-agent.d.ts +19 -6
- package/dist/src/ai/task-agent.js +37 -33
- package/dist/src/ai/tester.d.ts +6 -14
- package/dist/src/ai/tester.js +46 -111
- package/dist/src/ai/tools.d.ts +26 -14
- package/dist/src/ai/tools.js +138 -132
- package/dist/src/command-handler.js +1 -1
- package/dist/src/commands/compact-command.d.ts +1 -1
- package/dist/src/commands/compact-command.js +6 -6
- package/dist/src/commands/context-aria-command.js +1 -1
- package/dist/src/commands/context-command.js +8 -6
- package/dist/src/commands/context-data-command.js +2 -2
- package/dist/src/commands/context-experience-command.js +2 -2
- package/dist/src/commands/context-html-command.js +2 -2
- package/dist/src/commands/context-knowledge-command.js +2 -2
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/experience-command.js +1 -1
- package/dist/src/commands/explore-command.js +4 -3
- package/dist/src/commands/freesail-command.js +2 -2
- package/dist/src/commands/knows-command.js +1 -1
- package/dist/src/commands/learn-command.js +5 -3
- package/dist/src/commands/path-command.js +1 -1
- package/dist/src/commands/research-command.js +1 -1
- package/dist/src/commands/test-command.js +1 -1
- package/dist/src/components/AddKnowledge.d.ts +2 -0
- package/dist/src/components/AddKnowledge.js +3 -7
- package/dist/src/components/App.js +3 -3
- package/dist/src/config.d.ts +19 -0
- package/dist/src/config.js +156 -10
- package/dist/src/experience-tracker.d.ts +11 -6
- package/dist/src/experience-tracker.js +82 -155
- package/dist/src/explorbot.d.ts +21 -13
- package/dist/src/explorbot.js +84 -80
- package/dist/src/explorer.d.ts +66 -102
- package/dist/src/explorer.js +332 -623
- package/dist/src/knowledge-tracker.d.ts +2 -2
- package/dist/src/knowledge-tracker.js +38 -45
- package/dist/src/state-manager.d.ts +10 -30
- package/dist/src/state-manager.js +11 -129
- package/dist/src/utils/aria.d.ts +6 -1
- package/dist/src/utils/aria.js +4 -1
- package/dist/src/utils/cache.d.ts +15 -0
- package/dist/src/utils/cache.js +34 -0
- package/dist/src/utils/context-formatter.d.ts +6 -0
- package/dist/src/utils/context-formatter.js +15 -27
- package/dist/src/utils/hooks-runner.js +2 -4
- package/dist/src/utils/html.d.ts +5 -0
- package/dist/src/utils/html.js +71 -0
- package/dist/src/utils/markdown-files.d.ts +10 -0
- package/dist/src/utils/markdown-files.js +21 -0
- package/dist/src/utils/markdown-query.d.ts +6 -0
- package/dist/src/utils/markdown-query.js +14 -1
- package/dist/src/utils/page-readiness.d.ts +1 -0
- package/dist/src/utils/page-readiness.js +1 -1
- package/dist/src/utils/secrets.js +2 -2
- package/dist/src/utils/strings.d.ts +2 -0
- package/dist/src/utils/strings.js +10 -0
- package/dist/src/utils/test-files.js +1 -1
- package/dist/src/utils/web-annotate.d.ts +5 -0
- package/dist/src/utils/web-annotate.js +58 -0
- package/dist/src/utils/web-eidx.d.ts +2 -0
- package/dist/src/utils/web-eidx.js +20 -0
- package/dist/src/utils/web-element.d.ts +3 -1
- package/dist/src/utils/web-element.js +32 -0
- package/dist/src/utils/web-sandbox.d.ts +4 -0
- package/dist/src/utils/web-sandbox.js +37 -0
- package/models.json +30 -0
- package/package.json +23 -3
- package/src/action-result.ts +70 -173
- package/src/action.ts +38 -132
- package/src/ai/agent.ts +20 -0
- package/src/ai/captain/idle-mode.ts +1 -1
- package/src/ai/captain/test-mode.ts +1 -1
- package/src/ai/captain/web-mode.ts +18 -30
- package/src/ai/captain.ts +17 -42
- package/src/ai/conversation.ts +9 -32
- package/src/ai/driller.ts +53 -83
- package/src/ai/experience-compactor.ts +16 -35
- package/src/ai/fisherman-tools.ts +1 -1
- package/src/ai/fisherman.ts +0 -4
- package/src/ai/historian/codeceptjs.ts +5 -3
- package/src/ai/historian/playwright.ts +7 -4
- package/src/ai/historian/screencast.ts +4 -3
- package/src/ai/historian/utils.ts +0 -1
- package/src/ai/historian.ts +6 -8
- package/src/ai/navigator.ts +39 -70
- package/src/ai/pilot.ts +59 -61
- package/src/ai/planner.ts +76 -62
- package/src/ai/provider.ts +85 -146
- package/src/ai/quartermaster.ts +39 -45
- package/src/ai/rerunner.ts +27 -50
- package/src/ai/researcher/cache.ts +10 -16
- package/src/ai/researcher/coordinates.ts +4 -3
- package/src/ai/researcher/deep-analysis.ts +10 -11
- package/src/ai/researcher/locators.ts +66 -9
- package/src/ai/researcher/parser.ts +1 -1
- package/src/ai/researcher/research-result.ts +0 -18
- package/src/ai/researcher/sections.ts +9 -3
- package/src/ai/researcher.ts +42 -119
- package/src/ai/rules.ts +24 -13
- package/src/ai/session-analyst.ts +8 -5
- package/src/ai/task-agent.ts +45 -38
- package/src/ai/tester.ts +51 -129
- package/src/ai/tools.ts +145 -153
- package/src/command-handler.ts +1 -1
- package/src/commands/compact-command.ts +7 -8
- package/src/commands/context-aria-command.ts +1 -1
- package/src/commands/context-command.ts +8 -6
- package/src/commands/context-data-command.ts +2 -2
- package/src/commands/context-experience-command.ts +2 -2
- package/src/commands/context-html-command.ts +2 -2
- package/src/commands/context-knowledge-command.ts +2 -2
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/experience-command.ts +1 -1
- package/src/commands/explore-command.ts +4 -3
- package/src/commands/freesail-command.ts +2 -2
- package/src/commands/knows-command.ts +1 -1
- package/src/commands/learn-command.ts +5 -3
- package/src/commands/path-command.ts +1 -1
- package/src/commands/research-command.ts +1 -1
- package/src/commands/test-command.ts +1 -1
- package/src/components/AddKnowledge.tsx +5 -7
- package/src/components/App.tsx +3 -3
- package/src/config.ts +185 -11
- package/src/experience-tracker.ts +89 -152
- package/src/explorbot.ts +88 -83
- package/src/explorer.ts +402 -693
- package/src/knowledge-tracker.ts +42 -49
- package/src/state-manager.ts +20 -155
- package/src/utils/aria.ts +11 -2
- package/src/utils/cache.ts +40 -0
- package/src/utils/context-formatter.ts +12 -15
- package/src/utils/hooks-runner.ts +2 -4
- package/src/utils/html.ts +79 -0
- package/src/utils/markdown-files.ts +30 -0
- package/src/utils/markdown-query.ts +15 -1
- package/src/utils/page-readiness.ts +1 -1
- package/src/utils/secrets.ts +2 -3
- package/src/utils/strings.ts +12 -0
- package/src/utils/test-files.ts +1 -1
- package/src/utils/web-annotate.ts +64 -0
- package/src/utils/web-eidx.ts +21 -0
- package/src/utils/web-element.ts +34 -0
- package/src/utils/web-sandbox.ts +43 -0
- package/dist/boat/api-tester/example/apibot.config.js +0 -30
package/dist/src/config.js
CHANGED
|
@@ -6,10 +6,22 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
6
6
|
}
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
10
|
-
import
|
|
9
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import path, { basename, dirname, join, resolve } from 'node:path';
|
|
11
12
|
import { parseEnv } from 'node:util';
|
|
13
|
+
import matter from 'gray-matter';
|
|
12
14
|
import { log } from './utils/logger.js';
|
|
15
|
+
export const PROVIDERS = {
|
|
16
|
+
openai: async () => (await import('@ai-sdk/openai')).createOpenAI(),
|
|
17
|
+
anthropic: async () => (await import('@ai-sdk/anthropic')).createAnthropic(),
|
|
18
|
+
google: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI(),
|
|
19
|
+
groq: async () => (await import('@ai-sdk/groq')).createGroq(),
|
|
20
|
+
mistral: async () => (await import('@ai-sdk/mistral')).createMistral(),
|
|
21
|
+
openrouter: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter(),
|
|
22
|
+
sambanova: async () => (await import('sambanova-ai-provider')).createSambaNova(),
|
|
23
|
+
};
|
|
24
|
+
let cachedOutputRoot = null;
|
|
13
25
|
const config = {
|
|
14
26
|
playwright: {
|
|
15
27
|
browser: 'chromium',
|
|
@@ -20,8 +32,21 @@ const config = {
|
|
|
20
32
|
},
|
|
21
33
|
};
|
|
22
34
|
export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
|
|
35
|
+
export const EXPLORBOT_ENV_VARS = [
|
|
36
|
+
{ name: 'EXPLORBOT_AI_PROVIDER', required: true, description: 'Provider name; fills every model role from its recommended models. Turns on config-free mode' },
|
|
37
|
+
{ name: 'EXPLORBOT_AI_MODEL', description: 'Pins the main model — a model id for the provider, or a standalone provider/model-id' },
|
|
38
|
+
{ name: 'EXPLORBOT_URL', required: true, description: 'Base URL to test; the API boat reads it as the base endpoint' },
|
|
39
|
+
{ name: 'EXPLORBOT_VISION_MODEL', description: 'Screenshot analysis; overrides the provider recommendation' },
|
|
40
|
+
{ name: 'EXPLORBOT_AGENTIC_MODEL', description: 'Captain and Pilot decisions; overrides the provider recommendation' },
|
|
41
|
+
{ name: 'EXPLORBOT_OUTPUT', description: 'Output root for states, plans, research, and reports. Defaults to a fresh temp directory' },
|
|
42
|
+
{ name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
|
|
43
|
+
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
44
|
+
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
45
|
+
{ name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
|
|
46
|
+
];
|
|
23
47
|
export class ConfigParser {
|
|
24
48
|
static instance;
|
|
49
|
+
static recommended = null;
|
|
25
50
|
config = null;
|
|
26
51
|
configPath = null;
|
|
27
52
|
runtimeBaseUrlOverride = null;
|
|
@@ -32,6 +57,10 @@ export class ConfigParser {
|
|
|
32
57
|
return;
|
|
33
58
|
Object.assign(process.env, parseEnv(readFileSync(resolved, 'utf8')));
|
|
34
59
|
}
|
|
60
|
+
static recommendedModels() {
|
|
61
|
+
ConfigParser.recommended ||= JSON.parse(readFileSync(new URL('../models.json', import.meta.url), 'utf8'));
|
|
62
|
+
return ConfigParser.recommended;
|
|
63
|
+
}
|
|
35
64
|
static getInstance() {
|
|
36
65
|
if (!ConfigParser.instance) {
|
|
37
66
|
ConfigParser.instance = new ConfigParser();
|
|
@@ -54,18 +83,25 @@ export class ConfigParser {
|
|
|
54
83
|
ConfigParser.loadEnv('.env');
|
|
55
84
|
try {
|
|
56
85
|
const resolvedPath = options?.config || this.findConfigFile();
|
|
57
|
-
|
|
58
|
-
|
|
86
|
+
let loadedConfig = null;
|
|
87
|
+
let sourcePath = resolvedPath;
|
|
88
|
+
if (resolvedPath) {
|
|
89
|
+
const configModule = await this.loadConfigModule(resolvedPath);
|
|
90
|
+
loadedConfig = configModule.default || configModule;
|
|
91
|
+
if (!loadedConfig) {
|
|
92
|
+
throw new Error('Configuration file is empty or invalid');
|
|
93
|
+
}
|
|
94
|
+
log(`Configuration loaded from: ${resolvedPath}`);
|
|
59
95
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
if (!resolvedPath) {
|
|
97
|
+
const outputRoot = resolveOutputRoot();
|
|
98
|
+
loadedConfig = await this.buildEnvConfig(options?.baseUrl, outputRoot);
|
|
99
|
+
sourcePath = join(outputRoot, 'explorbot.config.js');
|
|
100
|
+
log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
|
|
64
101
|
}
|
|
65
102
|
this.config = this.resolveConfig(loadedConfig, options);
|
|
66
103
|
this.runtimeBaseUrlOverride = options?.baseUrl || null;
|
|
67
|
-
this.configPath =
|
|
68
|
-
log(`Configuration loaded from: ${resolvedPath}`);
|
|
104
|
+
this.configPath = sourcePath;
|
|
69
105
|
// Restore original directory after successful config load
|
|
70
106
|
if (options?.path && originalCwd !== process.cwd()) {
|
|
71
107
|
process.chdir(originalCwd);
|
|
@@ -96,6 +132,18 @@ export class ConfigParser {
|
|
|
96
132
|
throw new Error('Config path not found');
|
|
97
133
|
return path.join(path.dirname(configPath), config.dirs?.output || 'output');
|
|
98
134
|
}
|
|
135
|
+
getProjectRoot() {
|
|
136
|
+
const configPath = this.getConfigPath();
|
|
137
|
+
if (configPath)
|
|
138
|
+
return path.dirname(configPath);
|
|
139
|
+
return process.cwd();
|
|
140
|
+
}
|
|
141
|
+
resolveProjectDir(relativeDir) {
|
|
142
|
+
const configPath = this.getConfigPath();
|
|
143
|
+
if (!configPath)
|
|
144
|
+
return relativeDir;
|
|
145
|
+
return path.join(path.dirname(configPath), relativeDir);
|
|
146
|
+
}
|
|
99
147
|
getStatesDir() {
|
|
100
148
|
return outputPath('states');
|
|
101
149
|
}
|
|
@@ -107,6 +155,7 @@ export class ConfigParser {
|
|
|
107
155
|
}
|
|
108
156
|
// For testing purposes only
|
|
109
157
|
static resetForTesting() {
|
|
158
|
+
cachedOutputRoot = null;
|
|
110
159
|
if (ConfigParser.instance) {
|
|
111
160
|
ConfigParser.instance.config = null;
|
|
112
161
|
ConfigParser.instance.configPath = null;
|
|
@@ -157,6 +206,51 @@ export class ConfigParser {
|
|
|
157
206
|
// Ignore cleanup errors
|
|
158
207
|
}
|
|
159
208
|
}
|
|
209
|
+
async buildEnvConfig(baseUrl, outputRoot) {
|
|
210
|
+
const provider = process.env.EXPLORBOT_AI_PROVIDER;
|
|
211
|
+
const modelSpec = process.env.EXPLORBOT_AI_MODEL;
|
|
212
|
+
if (!provider && !modelSpec) {
|
|
213
|
+
throw new Error('No configuration file found. Please create explorbot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
|
|
214
|
+
}
|
|
215
|
+
if (modelSpec && !provider && !modelSpec.includes('/')) {
|
|
216
|
+
throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
|
|
217
|
+
}
|
|
218
|
+
const url = process.env.EXPLORBOT_URL || baseUrl;
|
|
219
|
+
if (!url) {
|
|
220
|
+
throw new Error('No URL to explore. Set EXPLORBOT_URL or pass a URL to the command');
|
|
221
|
+
}
|
|
222
|
+
materializeKnowledge(outputRoot);
|
|
223
|
+
let model;
|
|
224
|
+
if (provider && modelSpec)
|
|
225
|
+
model = await createModel(provider, modelSpec);
|
|
226
|
+
if (provider && !modelSpec)
|
|
227
|
+
model = await resolveModel(provider, 'model');
|
|
228
|
+
if (!provider)
|
|
229
|
+
model = await resolveModel(modelSpec, 'model');
|
|
230
|
+
const ai = {
|
|
231
|
+
model,
|
|
232
|
+
agents: { historian: { enabled: false } },
|
|
233
|
+
};
|
|
234
|
+
let recommended = {};
|
|
235
|
+
if (provider)
|
|
236
|
+
recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
237
|
+
const visionSpec = process.env.EXPLORBOT_VISION_MODEL;
|
|
238
|
+
if (visionSpec)
|
|
239
|
+
ai.visionModel = await resolveModel(visionSpec, 'visionModel');
|
|
240
|
+
if (!visionSpec && recommended.visionModel)
|
|
241
|
+
ai.visionModel = await resolveModel(provider, 'visionModel');
|
|
242
|
+
const agenticSpec = process.env.EXPLORBOT_AGENTIC_MODEL;
|
|
243
|
+
if (agenticSpec)
|
|
244
|
+
ai.agenticModel = await resolveModel(agenticSpec, 'agenticModel');
|
|
245
|
+
if (!agenticSpec && recommended.agenticModel)
|
|
246
|
+
ai.agenticModel = await resolveModel(provider, 'agenticModel');
|
|
247
|
+
return {
|
|
248
|
+
playwright: { browser: 'chromium', url, show: false },
|
|
249
|
+
ai,
|
|
250
|
+
dirs: { knowledge: 'knowledge', experience: 'experience', output: '.' },
|
|
251
|
+
experience: { disabled: true },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
160
254
|
findConfigFile() {
|
|
161
255
|
const possiblePaths = [...EXPLORBOT_CONFIG_PATHS, 'config/explorbot.config.js', 'config/explorbot.config.mjs', 'config/explorbot.config.ts', 'src/config/explorbot.config.js', 'src/config/explorbot.config.mjs', 'src/config/explorbot.config.ts'];
|
|
162
256
|
for (const path of possiblePaths) {
|
|
@@ -256,3 +350,55 @@ export class ConfigParser {
|
|
|
256
350
|
export function outputPath(...segments) {
|
|
257
351
|
return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
|
|
258
352
|
}
|
|
353
|
+
export async function resolveModel(spec, role = 'model') {
|
|
354
|
+
const separator = spec.indexOf('/');
|
|
355
|
+
if (separator > 0) {
|
|
356
|
+
return createModel(spec.slice(0, separator), spec.slice(separator + 1));
|
|
357
|
+
}
|
|
358
|
+
const recommended = ConfigParser.recommendedModels()[spec];
|
|
359
|
+
if (!recommended) {
|
|
360
|
+
throw new Error(`No recommended models for "${spec}". Write it as "provider/model-id", or use a provider with recommendations: ${Object.keys(ConfigParser.recommendedModels()).join(', ')}`);
|
|
361
|
+
}
|
|
362
|
+
const modelId = recommended[role];
|
|
363
|
+
if (!modelId) {
|
|
364
|
+
throw new Error(`Provider "${spec}" has no recommended ${role}. Set it explicitly as "provider/model-id".`);
|
|
365
|
+
}
|
|
366
|
+
return createModel(spec, modelId);
|
|
367
|
+
}
|
|
368
|
+
export function resolveOutputRoot() {
|
|
369
|
+
if (cachedOutputRoot)
|
|
370
|
+
return cachedOutputRoot;
|
|
371
|
+
const configured = process.env.EXPLORBOT_OUTPUT;
|
|
372
|
+
if (!configured) {
|
|
373
|
+
cachedOutputRoot = mkdtempSync(join(tmpdir(), 'explorbot-'));
|
|
374
|
+
return cachedOutputRoot;
|
|
375
|
+
}
|
|
376
|
+
cachedOutputRoot = resolve(configured);
|
|
377
|
+
mkdirSync(cachedOutputRoot, { recursive: true });
|
|
378
|
+
return cachedOutputRoot;
|
|
379
|
+
}
|
|
380
|
+
export function materializeKnowledge(outputRoot) {
|
|
381
|
+
const inline = process.env.EXPLORBOT_KNOWLEDGE;
|
|
382
|
+
const knowledgeFile = process.env.EXPLORBOT_KNOWLEDGE_FILE;
|
|
383
|
+
if (!inline && !knowledgeFile)
|
|
384
|
+
return;
|
|
385
|
+
const knowledgeDir = join(outputRoot, 'knowledge');
|
|
386
|
+
mkdirSync(knowledgeDir, { recursive: true });
|
|
387
|
+
if (inline) {
|
|
388
|
+
writeFileSync(join(knowledgeDir, 'global.md'), matter.stringify(inline, { url: '*', endpoint: '*' }));
|
|
389
|
+
}
|
|
390
|
+
if (!knowledgeFile)
|
|
391
|
+
return;
|
|
392
|
+
const source = resolve(knowledgeFile);
|
|
393
|
+
if (!existsSync(source)) {
|
|
394
|
+
throw new Error(`Knowledge file from EXPLORBOT_KNOWLEDGE_FILE not found: ${source}`);
|
|
395
|
+
}
|
|
396
|
+
copyFileSync(source, join(knowledgeDir, basename(source)));
|
|
397
|
+
}
|
|
398
|
+
export async function createModel(provider, modelId) {
|
|
399
|
+
const factory = PROVIDERS[provider];
|
|
400
|
+
if (!factory) {
|
|
401
|
+
throw new Error(`Unknown AI provider "${provider}". Supported providers: ${Object.keys(PROVIDERS).join(', ')}`);
|
|
402
|
+
}
|
|
403
|
+
return (await factory())(modelId);
|
|
404
|
+
}
|
|
@@ -22,7 +22,7 @@ export declare class ExperienceTracker {
|
|
|
22
22
|
experienceDir: string;
|
|
23
23
|
disabled: boolean;
|
|
24
24
|
knowledgeTracker: KnowledgeTracker;
|
|
25
|
-
constructor(options?: {
|
|
25
|
+
constructor(knowledgeTracker: KnowledgeTracker, options?: {
|
|
26
26
|
disabled?: boolean;
|
|
27
27
|
});
|
|
28
28
|
getExperienceDirectories(): string[];
|
|
@@ -31,8 +31,11 @@ export declare class ExperienceTracker {
|
|
|
31
31
|
content: string;
|
|
32
32
|
data: any;
|
|
33
33
|
};
|
|
34
|
+
readExperienceFileAt(filePath: string): {
|
|
35
|
+
content: string;
|
|
36
|
+
data: any;
|
|
37
|
+
};
|
|
34
38
|
writeExperienceFile(stateHash: string, content: string, frontmatter?: any): void;
|
|
35
|
-
hasRecentExperience(stateHash: string, prefix?: string): boolean;
|
|
36
39
|
getExperienceFilePath(stateHash: string): string;
|
|
37
40
|
ensureExperienceFile(state: ActionResult): string;
|
|
38
41
|
updateSummary(state: ActionResult, summary: string): void;
|
|
@@ -43,10 +46,6 @@ export declare class ExperienceTracker {
|
|
|
43
46
|
getRelevantExperience(state: ActionResult, options?: {
|
|
44
47
|
includeDescendantExperience?: boolean;
|
|
45
48
|
}): ExperienceFile[];
|
|
46
|
-
/**
|
|
47
|
-
* Clean up experience tracker (for testing)
|
|
48
|
-
*/
|
|
49
|
-
cleanup(): void;
|
|
50
49
|
getSuccessfulExperience(state: ActionResult, options?: {
|
|
51
50
|
includeDescendants?: boolean;
|
|
52
51
|
stripCode?: boolean;
|
|
@@ -54,6 +53,7 @@ export declare class ExperienceTracker {
|
|
|
54
53
|
getExperienceTableOfContents(state: ActionResult, options?: {
|
|
55
54
|
includeDescendantExperience?: boolean;
|
|
56
55
|
}): ExperienceTocEntry[];
|
|
56
|
+
renderExperienceTocFor(state: ActionResult): string;
|
|
57
57
|
getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: {
|
|
58
58
|
includeDescendantExperience?: boolean;
|
|
59
59
|
}): {
|
|
@@ -61,6 +61,11 @@ export declare class ExperienceTracker {
|
|
|
61
61
|
url: string;
|
|
62
62
|
content: string;
|
|
63
63
|
} | null;
|
|
64
|
+
buildToc(records: ExperienceFile[]): ExperienceTocEntry[];
|
|
65
|
+
readSectionByHash(fileHash: string, sectionIndex: number): {
|
|
66
|
+
title: string;
|
|
67
|
+
body: string;
|
|
68
|
+
} | null;
|
|
64
69
|
findExperienceFileByHash(fileHash: string): string | null;
|
|
65
70
|
listAllExperienceToc(filter?: string, options?: {
|
|
66
71
|
recency?: 'recent' | 'old';
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
2
|
-
import { basename,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
|
-
import { marked } from 'marked';
|
|
5
4
|
import { ConfigParser } from './config.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { createDebug, pluralize, tag } from './utils/logger.js';
|
|
6
|
+
import { loadMarkdownFiles } from './utils/markdown-files.js';
|
|
8
7
|
import { mdq } from './utils/markdown-query.js';
|
|
9
8
|
import { redactSecrets } from './utils/secrets.js';
|
|
10
9
|
import { isNonReusableCode } from "./utils/step-analyzer.js";
|
|
@@ -33,20 +32,13 @@ export class ExperienceTracker {
|
|
|
33
32
|
experienceDir;
|
|
34
33
|
disabled;
|
|
35
34
|
knowledgeTracker;
|
|
36
|
-
constructor(options = {}) {
|
|
35
|
+
constructor(knowledgeTracker, options = {}) {
|
|
37
36
|
const configParser = ConfigParser.getInstance();
|
|
38
37
|
const config = configParser.getConfig();
|
|
39
|
-
|
|
40
|
-
this.
|
|
41
|
-
this.knowledgeTracker = new KnowledgeTracker();
|
|
38
|
+
this.disabled = options.disabled ?? config.experience?.disabled ?? false;
|
|
39
|
+
this.knowledgeTracker = knowledgeTracker;
|
|
42
40
|
// Resolve experience directory relative to the config file location (project root)
|
|
43
|
-
|
|
44
|
-
const projectRoot = dirname(configPath);
|
|
45
|
-
this.experienceDir = join(projectRoot, config.dirs?.experience || 'experience');
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
this.experienceDir = config.dirs?.experience || 'experience';
|
|
49
|
-
}
|
|
41
|
+
this.experienceDir = configParser.resolveProjectDir(config.dirs?.experience || 'experience');
|
|
50
42
|
if (!this.disabled) {
|
|
51
43
|
this.ensureDirectory(this.experienceDir);
|
|
52
44
|
}
|
|
@@ -84,7 +76,9 @@ export class ExperienceTracker {
|
|
|
84
76
|
}
|
|
85
77
|
}
|
|
86
78
|
readExperienceFile(stateHash) {
|
|
87
|
-
|
|
79
|
+
return this.readExperienceFileAt(this.getExperienceFilePath(stateHash));
|
|
80
|
+
}
|
|
81
|
+
readExperienceFileAt(filePath) {
|
|
88
82
|
const fileContent = readFileSync(filePath, 'utf8');
|
|
89
83
|
const { content, data } = matter(fileContent);
|
|
90
84
|
return { content, data };
|
|
@@ -97,20 +91,6 @@ export class ExperienceTracker {
|
|
|
97
91
|
const fileContent = matter.stringify(redactSecrets(content), frontmatter || {});
|
|
98
92
|
writeFileSync(filePath, fileContent, 'utf8');
|
|
99
93
|
}
|
|
100
|
-
hasRecentExperience(stateHash, prefix = '') {
|
|
101
|
-
if (this.disabled) {
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
if (prefix) {
|
|
105
|
-
stateHash = `${prefix}_${stateHash}`;
|
|
106
|
-
}
|
|
107
|
-
const filePath = this.getExperienceFilePath(stateHash);
|
|
108
|
-
if (!existsSync(filePath)) {
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
|
-
const stats = statSync(filePath);
|
|
112
|
-
return stats.mtime.getTime() > Date.now() - 1000 * 60 * 60 * 24;
|
|
113
|
-
}
|
|
114
94
|
getExperienceFilePath(stateHash) {
|
|
115
95
|
return join(this.experienceDir, `${stateHash}.md`);
|
|
116
96
|
}
|
|
@@ -154,7 +134,10 @@ export class ExperienceTracker {
|
|
|
154
134
|
this.ensureExperienceFile(state);
|
|
155
135
|
const stateHash = state.getStateHash();
|
|
156
136
|
const { content, data } = this.readExperienceFile(stateHash);
|
|
157
|
-
if (content
|
|
137
|
+
if (mdq(content)
|
|
138
|
+
.query('code')
|
|
139
|
+
.meta()
|
|
140
|
+
.some((m) => m.text.trim() === action.code.trim())) {
|
|
158
141
|
debugLog('Skipping duplicate action', action.code);
|
|
159
142
|
return;
|
|
160
143
|
}
|
|
@@ -179,7 +162,10 @@ export class ExperienceTracker {
|
|
|
179
162
|
this.ensureExperienceFile(state);
|
|
180
163
|
const stateHash = state.getStateHash();
|
|
181
164
|
const { content, data } = this.readExperienceFile(stateHash);
|
|
182
|
-
if (content
|
|
165
|
+
if (mdq(content)
|
|
166
|
+
.query('section2')
|
|
167
|
+
.each()
|
|
168
|
+
.some((s) => s.text().trim() === body.trim())) {
|
|
183
169
|
debugLog('Skipping duplicate flow body');
|
|
184
170
|
return;
|
|
185
171
|
}
|
|
@@ -200,25 +186,9 @@ export class ExperienceTracker {
|
|
|
200
186
|
continue;
|
|
201
187
|
}
|
|
202
188
|
try {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
for (const file of files) {
|
|
207
|
-
try {
|
|
208
|
-
const content = readFileSync(file, 'utf8');
|
|
209
|
-
const parsed = matter(content);
|
|
210
|
-
const mtime = statSync(file).mtime;
|
|
211
|
-
allFiles.push({
|
|
212
|
-
filePath: file,
|
|
213
|
-
data: parsed.data,
|
|
214
|
-
content: parsed.content,
|
|
215
|
-
mtime,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
catch (error) {
|
|
219
|
-
debugLog(`Failed to read experience file ${file}:`, error);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
189
|
+
allFiles.push(...loadMarkdownFiles(experienceDir, {
|
|
190
|
+
onError: (filePath, error) => debugLog(`Failed to read experience file ${filePath}:`, error),
|
|
191
|
+
}));
|
|
222
192
|
}
|
|
223
193
|
catch (error) {
|
|
224
194
|
debugLog(`Failed to read experience directory ${experienceDir}:`, error);
|
|
@@ -242,19 +212,18 @@ export class ExperienceTracker {
|
|
|
242
212
|
});
|
|
243
213
|
})
|
|
244
214
|
.map((experience) => {
|
|
245
|
-
|
|
246
|
-
if (lines.length <= maxLines)
|
|
215
|
+
if (experience.content.split('\n').length <= maxLines)
|
|
247
216
|
return experience;
|
|
248
|
-
|
|
217
|
+
let content = experience.content;
|
|
218
|
+
while (content.split('\n').length > maxLines) {
|
|
219
|
+
const sections = mdq(content).query('section2').each();
|
|
220
|
+
if (sections.length <= 1)
|
|
221
|
+
break;
|
|
222
|
+
content = sections[sections.length - 1].replace('');
|
|
223
|
+
}
|
|
224
|
+
return { ...experience, content };
|
|
249
225
|
});
|
|
250
226
|
}
|
|
251
|
-
/**
|
|
252
|
-
* Clean up experience tracker (for testing)
|
|
253
|
-
*/
|
|
254
|
-
cleanup() {
|
|
255
|
-
// Clear any in-memory state if needed
|
|
256
|
-
// The actual files will be cleaned up by test cleanup
|
|
257
|
-
}
|
|
258
227
|
getSuccessfulExperience(state, options) {
|
|
259
228
|
const records = this.getRelevantExperience(state, {
|
|
260
229
|
includeDescendantExperience: options?.includeDescendants,
|
|
@@ -286,36 +255,48 @@ export class ExperienceTracker {
|
|
|
286
255
|
const bHash = basename(b.filePath, '.md');
|
|
287
256
|
return aHash.localeCompare(bHash);
|
|
288
257
|
});
|
|
258
|
+
return this.buildToc(sorted);
|
|
259
|
+
}
|
|
260
|
+
renderExperienceTocFor(state) {
|
|
261
|
+
const toc = this.getExperienceTableOfContents(state);
|
|
262
|
+
if (toc.length === 0)
|
|
263
|
+
return '';
|
|
264
|
+
const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
|
|
265
|
+
debugLog(`injecting experience TOC (${toc.length} files, ${totalSections} sections)`);
|
|
266
|
+
tag('operation').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
|
|
267
|
+
return renderExperienceToc(toc);
|
|
268
|
+
}
|
|
269
|
+
getExperienceSection(fileTag, sectionIndex, state, options) {
|
|
270
|
+
const toc = this.getExperienceTableOfContents(state, options);
|
|
271
|
+
const entry = toc.find((e) => e.fileTag === fileTag);
|
|
272
|
+
if (!entry)
|
|
273
|
+
return null;
|
|
274
|
+
const extracted = this.readSectionByHash(entry.fileHash, sectionIndex);
|
|
275
|
+
if (!extracted)
|
|
276
|
+
return null;
|
|
277
|
+
return { title: extracted.title, url: entry.url, content: extracted.body };
|
|
278
|
+
}
|
|
279
|
+
buildToc(records) {
|
|
289
280
|
const toc = [];
|
|
290
|
-
for (
|
|
291
|
-
const record = sorted[i];
|
|
292
|
-
const fileHash = basename(record.filePath, '.md');
|
|
293
|
-
const url = record.data?.url || '';
|
|
281
|
+
for (const record of records) {
|
|
294
282
|
const sections = listTocHeadings(record.content);
|
|
295
283
|
if (sections.length === 0)
|
|
296
284
|
continue;
|
|
297
285
|
toc.push({
|
|
298
|
-
fileTag: indexToLetters(
|
|
299
|
-
fileHash,
|
|
300
|
-
url,
|
|
286
|
+
fileTag: indexToLetters(toc.length),
|
|
287
|
+
fileHash: basename(record.filePath, '.md'),
|
|
288
|
+
url: record.data?.url || '',
|
|
301
289
|
sections,
|
|
302
290
|
});
|
|
303
291
|
}
|
|
304
292
|
return toc;
|
|
305
293
|
}
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
const entry = toc.find((e) => e.fileTag === fileTag);
|
|
309
|
-
if (!entry)
|
|
310
|
-
return null;
|
|
311
|
-
const filePath = this.findExperienceFileByHash(entry.fileHash);
|
|
294
|
+
readSectionByHash(fileHash, sectionIndex) {
|
|
295
|
+
const filePath = this.findExperienceFileByHash(fileHash);
|
|
312
296
|
if (!filePath)
|
|
313
297
|
return null;
|
|
314
|
-
const { content } = this.
|
|
315
|
-
|
|
316
|
-
if (!extracted)
|
|
317
|
-
return null;
|
|
318
|
-
return { title: extracted.title, url: entry.url, content: extracted.body };
|
|
298
|
+
const { content } = this.readExperienceFileAt(filePath);
|
|
299
|
+
return extractHeadingSection(content, sectionIndex);
|
|
319
300
|
}
|
|
320
301
|
findExperienceFileByHash(fileHash) {
|
|
321
302
|
for (const dir of this.getExperienceDirectories()) {
|
|
@@ -359,81 +340,34 @@ export class ExperienceTracker {
|
|
|
359
340
|
const bUrl = b.data?.url || '';
|
|
360
341
|
return aUrl.localeCompare(bUrl);
|
|
361
342
|
});
|
|
362
|
-
|
|
363
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
364
|
-
const record = sorted[i];
|
|
365
|
-
const sections = listTocHeadings(record.content);
|
|
366
|
-
if (sections.length === 0)
|
|
367
|
-
continue;
|
|
368
|
-
toc.push({
|
|
369
|
-
fileTag: indexToLetters(toc.length),
|
|
370
|
-
fileHash: basename(record.filePath, '.md'),
|
|
371
|
-
url: record.data?.url || '',
|
|
372
|
-
sections,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
return toc;
|
|
343
|
+
return this.buildToc(sorted);
|
|
376
344
|
}
|
|
377
345
|
getExperienceSectionByTag(fileTag, sectionIndex, filter) {
|
|
378
346
|
const toc = this.listAllExperienceToc(filter);
|
|
379
347
|
const entry = toc.find((e) => e.fileTag === fileTag);
|
|
380
348
|
if (!entry)
|
|
381
349
|
return null;
|
|
382
|
-
const
|
|
383
|
-
if (!filePath)
|
|
384
|
-
return null;
|
|
385
|
-
const { content } = this.readExperienceFile(entry.fileHash);
|
|
386
|
-
const extracted = extractHeadingSection(content, sectionIndex);
|
|
350
|
+
const extracted = this.readSectionByHash(entry.fileHash, sectionIndex);
|
|
387
351
|
if (!extracted)
|
|
388
352
|
return null;
|
|
389
353
|
return { title: extracted.title, url: entry.url, content: extracted.body, fileHash: entry.fileHash };
|
|
390
354
|
}
|
|
391
355
|
}
|
|
392
356
|
function listTocHeadings(content) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
continue;
|
|
399
|
-
const heading = token;
|
|
400
|
-
if (heading.depth !== 2 && heading.depth !== 3)
|
|
401
|
-
continue;
|
|
402
|
-
index++;
|
|
403
|
-
result.push({ index, level: heading.depth, title: heading.text });
|
|
404
|
-
}
|
|
405
|
-
return result;
|
|
357
|
+
return mdq(content)
|
|
358
|
+
.query('heading')
|
|
359
|
+
.meta()
|
|
360
|
+
.filter((m) => m.depth === 2 || m.depth === 3)
|
|
361
|
+
.map((m, i) => ({ index: i + 1, level: m.depth, title: m.text }));
|
|
406
362
|
}
|
|
407
363
|
function extractHeadingSection(content, sectionIndex) {
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
const token = tokens[i];
|
|
412
|
-
if (token.type !== 'heading')
|
|
413
|
-
continue;
|
|
414
|
-
const heading = token;
|
|
415
|
-
if (heading.depth !== 2 && heading.depth !== 3)
|
|
416
|
-
continue;
|
|
417
|
-
matching.push({ tokenIdx: i, depth: heading.depth, text: heading.text });
|
|
418
|
-
}
|
|
364
|
+
const sections = mdq(content).query('section').each();
|
|
365
|
+
const metas = mdq(content).query('section').meta();
|
|
366
|
+
const matching = metas.map((meta, i) => ({ meta, section: sections[i] })).filter((entry) => entry.meta.depth === 2 || entry.meta.depth === 3);
|
|
419
367
|
if (sectionIndex < 1 || sectionIndex > matching.length)
|
|
420
368
|
return null;
|
|
421
369
|
const target = matching[sectionIndex - 1];
|
|
422
|
-
|
|
423
|
-
for (let j = target.tokenIdx + 1; j < tokens.length; j++) {
|
|
424
|
-
const token = tokens[j];
|
|
425
|
-
if (token.type !== 'heading')
|
|
426
|
-
continue;
|
|
427
|
-
if (token.depth <= target.depth) {
|
|
428
|
-
endTokenIdx = j;
|
|
429
|
-
break;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
const body = tokens
|
|
433
|
-
.slice(target.tokenIdx, endTokenIdx)
|
|
434
|
-
.map((t) => t.raw || '')
|
|
435
|
-
.join('');
|
|
436
|
-
return { title: target.text, body };
|
|
370
|
+
return { title: target.meta.text, body: target.section.text() };
|
|
437
371
|
}
|
|
438
372
|
function indexToLetters(index) {
|
|
439
373
|
let n = index;
|
|
@@ -499,21 +433,14 @@ function generateActionContent(title, code, explanation) {
|
|
|
499
433
|
return lines.join('\n');
|
|
500
434
|
}
|
|
501
435
|
function renderAsHowTo(content) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
result += `## HOW to ${text.slice(7).trim()} (single-step)\n\n`;
|
|
513
|
-
continue;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
result += token.raw || '';
|
|
517
|
-
}
|
|
518
|
-
return result;
|
|
436
|
+
return mdq(content)
|
|
437
|
+
.query('h2')
|
|
438
|
+
.replaceEach((heading) => {
|
|
439
|
+
const text = heading.meta()[0]?.text.trim() || '';
|
|
440
|
+
if (text.startsWith('FLOW:'))
|
|
441
|
+
return `## HOW to ${text.slice(5).trim()} (multi-step)\n\n`;
|
|
442
|
+
if (text.startsWith('ACTION:'))
|
|
443
|
+
return `## HOW to ${text.slice(7).trim()} (single-step)\n\n`;
|
|
444
|
+
return heading.text();
|
|
445
|
+
});
|
|
519
446
|
}
|