@ekkos/cli 1.3.9 → 1.4.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.
- package/LICENSE +21 -0
- package/dist/commands/dashboard.js +520 -42
- package/dist/commands/gemini.d.ts +1 -0
- package/dist/commands/gemini.js +170 -10
- package/dist/commands/init-living-docs.d.ts +6 -0
- package/dist/commands/init-living-docs.js +57 -0
- package/dist/commands/living-docs.js +3 -3
- package/dist/commands/run.js +84 -20
- package/dist/commands/setup-ci.d.ts +3 -0
- package/dist/commands/setup-ci.js +107 -0
- package/dist/commands/validate-living-docs.d.ts +27 -0
- package/dist/commands/validate-living-docs.js +489 -0
- package/dist/index.js +109 -82
- package/dist/utils/state.d.ts +16 -3
- package/dist/utils/state.js +10 -3
- package/package.json +7 -10
package/dist/commands/gemini.js
CHANGED
|
@@ -62,6 +62,8 @@ const isWindows = process.platform === 'win32';
|
|
|
62
62
|
const PULSE_LOADED_TEXT = ' 🧠 ekkOS_Pulse Loaded!';
|
|
63
63
|
const PULSE_SHINE_FRAME_MS = 50;
|
|
64
64
|
const PULSE_SHINE_SWEEPS = 2;
|
|
65
|
+
const GEMINI_DEFAULT_MODEL = 'gemini-3.1-flash-lite-preview';
|
|
66
|
+
const GEMINI_MAX_OUTPUT_TOKENS = 65536;
|
|
65
67
|
function sleep(ms) {
|
|
66
68
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
67
69
|
}
|
|
@@ -121,6 +123,159 @@ function resolveGeminiPath() {
|
|
|
121
123
|
// 3. Ultimate fallback — let spawn resolve it (will error with helpful message)
|
|
122
124
|
return 'gemini';
|
|
123
125
|
}
|
|
126
|
+
function extractGeminiCliArgs() {
|
|
127
|
+
const geminiIdx = process.argv.indexOf('gemini');
|
|
128
|
+
if (geminiIdx === -1)
|
|
129
|
+
return [];
|
|
130
|
+
const rawArgs = process.argv.slice(geminiIdx + 1);
|
|
131
|
+
const filtered = [];
|
|
132
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
133
|
+
const arg = rawArgs[i];
|
|
134
|
+
if (!arg)
|
|
135
|
+
continue;
|
|
136
|
+
if (arg === '--skip-proxy' || arg === '-v' || arg === '--verbose' || arg === '--dashboard') {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (arg === '-s' || arg === '--session') {
|
|
140
|
+
i += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
filtered.push(arg);
|
|
144
|
+
}
|
|
145
|
+
return filtered;
|
|
146
|
+
}
|
|
147
|
+
function resolveGeminiLaunchModel(args) {
|
|
148
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
149
|
+
const arg = args[i];
|
|
150
|
+
if (!arg)
|
|
151
|
+
continue;
|
|
152
|
+
if ((arg === '-m' || arg === '--model') && typeof args[i + 1] === 'string') {
|
|
153
|
+
return args[i + 1];
|
|
154
|
+
}
|
|
155
|
+
if (arg.startsWith('--model=')) {
|
|
156
|
+
const value = arg.slice('--model='.length).trim();
|
|
157
|
+
if (value)
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const envModel = (process.env.GEMINI_MODEL || process.env.GOOGLE_GEMINI_MODEL || '').trim();
|
|
162
|
+
return envModel || GEMINI_DEFAULT_MODEL;
|
|
163
|
+
}
|
|
164
|
+
function resolveGeminiContextSize(model) {
|
|
165
|
+
const normalized = (model || '').trim().toLowerCase();
|
|
166
|
+
if (normalized.startsWith('gemini-3.1-pro') || normalized.startsWith('gemini-3-pro')) {
|
|
167
|
+
return 2097152;
|
|
168
|
+
}
|
|
169
|
+
return 1048576;
|
|
170
|
+
}
|
|
171
|
+
function resolveGeminiProjectId(projectPath) {
|
|
172
|
+
try {
|
|
173
|
+
const projectsPath = path.join(os.homedir(), '.gemini', 'projects.json');
|
|
174
|
+
if (!fs.existsSync(projectsPath))
|
|
175
|
+
return undefined;
|
|
176
|
+
const parsed = JSON.parse(fs.readFileSync(projectsPath, 'utf-8'));
|
|
177
|
+
const projectId = parsed?.projects?.[projectPath];
|
|
178
|
+
return typeof projectId === 'string' && projectId.length > 0 ? projectId : undefined;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function getGeminiSessionMetadata(options, args) {
|
|
185
|
+
const model = resolveGeminiLaunchModel(args);
|
|
186
|
+
return {
|
|
187
|
+
provider: 'gemini',
|
|
188
|
+
claudeModel: model,
|
|
189
|
+
claudeLaunchModel: model,
|
|
190
|
+
claudeContextWindow: '1m',
|
|
191
|
+
claudeContextSize: resolveGeminiContextSize(model),
|
|
192
|
+
claudeMaxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS,
|
|
193
|
+
geminiProjectId: resolveGeminiProjectId(process.cwd()),
|
|
194
|
+
dashboardEnabled: options.dashboard === true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function writeGeminiSessionFiles(sessionId, sessionName, metadata) {
|
|
198
|
+
try {
|
|
199
|
+
const ekkosDir = path.join(os.homedir(), '.ekkos');
|
|
200
|
+
if (!fs.existsSync(ekkosDir))
|
|
201
|
+
fs.mkdirSync(ekkosDir, { recursive: true });
|
|
202
|
+
const now = new Date().toISOString();
|
|
203
|
+
fs.writeFileSync(path.join(ekkosDir, 'current-session.json'), JSON.stringify({ session_id: sessionId, session_name: sessionName, timestamp: now, ...metadata }, null, 2));
|
|
204
|
+
fs.writeFileSync(path.join(ekkosDir, 'session-hint.json'), JSON.stringify({
|
|
205
|
+
session_id: sessionId,
|
|
206
|
+
session_name: sessionName,
|
|
207
|
+
project_path: process.cwd(),
|
|
208
|
+
timestamp: now,
|
|
209
|
+
pid: process.pid,
|
|
210
|
+
...metadata,
|
|
211
|
+
}, null, 2));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Non-fatal
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function shellQuote(value) {
|
|
218
|
+
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
|
219
|
+
}
|
|
220
|
+
function launchGeminiWithDashboard(options, extraArgs) {
|
|
221
|
+
(0, state_1.getActiveSessions)();
|
|
222
|
+
const tmuxSession = `ekkos-gemini-${Date.now().toString(36)}`;
|
|
223
|
+
const sessionName = options.session || (0, state_1.uuidToWords)(crypto.randomUUID());
|
|
224
|
+
const sessionId = crypto.randomUUID();
|
|
225
|
+
options.session = sessionName;
|
|
226
|
+
cliSessionName = sessionName;
|
|
227
|
+
cliSessionId = sessionId;
|
|
228
|
+
const metadata = getGeminiSessionMetadata({ ...options, session: sessionName, dashboard: true }, extraArgs);
|
|
229
|
+
const ekkosCmd = process.argv[1];
|
|
230
|
+
const cwd = process.cwd();
|
|
231
|
+
const termCols = process.stdout.columns ?? 160;
|
|
232
|
+
const termRows = process.stdout.rows ?? 48;
|
|
233
|
+
writeGeminiSessionFiles(sessionId, sessionName, metadata);
|
|
234
|
+
const geminiArgs = ['gemini', '-s', sessionName];
|
|
235
|
+
if (options.verbose)
|
|
236
|
+
geminiArgs.push('-v');
|
|
237
|
+
if (options.noProxy)
|
|
238
|
+
geminiArgs.push('--skip-proxy');
|
|
239
|
+
geminiArgs.push(...extraArgs);
|
|
240
|
+
const runCommand = `EKKOS_NO_SPLASH=1 node ${shellQuote(ekkosCmd)} ${geminiArgs.map(shellQuote).join(' ')}`;
|
|
241
|
+
const dashCommand = `node ${shellQuote(ekkosCmd)} dashboard ${shellQuote(sessionName)} --provider gemini --refresh 2000`;
|
|
242
|
+
try {
|
|
243
|
+
(0, child_process_1.execSync)(`tmux new-session -d -s "${tmuxSession}" -x ${termCols} -y ${termRows} -n "gemini" 'sleep 86400'`, { stdio: 'pipe' });
|
|
244
|
+
const applyTmuxOpt = (cmd) => {
|
|
245
|
+
try {
|
|
246
|
+
(0, child_process_1.execSync)(`tmux ${cmd}`, { stdio: 'pipe' });
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
if (options.verbose) {
|
|
250
|
+
console.log(chalk_1.default.gray(` tmux option skipped: ${cmd} (${err.message})`));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
applyTmuxOpt(`set-option -t "${tmuxSession}" default-terminal "xterm-256color"`);
|
|
255
|
+
applyTmuxOpt(`set-option -sa -t "${tmuxSession}" terminal-overrides ",xterm-256color:Tc:smcup@:rmcup@"`);
|
|
256
|
+
applyTmuxOpt(`set-option -t "${tmuxSession}" mouse on`);
|
|
257
|
+
const detachCleanupHook = `if-shell -F '#{==:#{session_attached},0}' 'kill-session -t ${tmuxSession}'`;
|
|
258
|
+
applyTmuxOpt(`set-hook -t "${tmuxSession}" client-detached "${detachCleanupHook}"`);
|
|
259
|
+
applyTmuxOpt(`set-window-option -t "${tmuxSession}" history-limit 100000`);
|
|
260
|
+
applyTmuxOpt(`set-window-option -t "${tmuxSession}" mode-keys vi`);
|
|
261
|
+
applyTmuxOpt(`set-window-option -t "${tmuxSession}:gemini" synchronize-panes off`);
|
|
262
|
+
applyTmuxOpt(`set-window-option -t "${tmuxSession}:gemini" window-size latest`);
|
|
263
|
+
applyTmuxOpt(`set-window-option -t "${tmuxSession}:gemini" aggressive-resize on`);
|
|
264
|
+
applyTmuxOpt(`set-option -t "${tmuxSession}" remain-on-exit off`);
|
|
265
|
+
applyTmuxOpt(`set-option -t "${tmuxSession}" escape-time 0`);
|
|
266
|
+
(0, child_process_1.execSync)(`tmux split-window -t "${tmuxSession}:gemini" -h -p 40 -c "${cwd}" ${shellQuote(dashCommand)}`, { stdio: 'pipe' });
|
|
267
|
+
(0, child_process_1.execSync)(`tmux respawn-pane -k -t "${tmuxSession}:gemini.0" ${shellQuote(runCommand)}`, { stdio: 'pipe' });
|
|
268
|
+
(0, child_process_1.execSync)(`tmux select-pane -t "${tmuxSession}:gemini.0"`, { stdio: 'pipe' });
|
|
269
|
+
(0, child_process_1.execSync)(`tmux attach -t "${tmuxSession}"`, { stdio: 'inherit' });
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
console.error(chalk_1.default.red(`\n ✘ Failed to launch Gemini dashboard split: ${err.message}\n`));
|
|
274
|
+
console.log(chalk_1.default.gray(' Falling back to normal Gemini mode.'));
|
|
275
|
+
console.log(chalk_1.default.gray(' Run "ekkos dashboard --latest --provider gemini" in another terminal.\n'));
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
124
279
|
/**
|
|
125
280
|
* Build environment for Gemini CLI with proxy routing.
|
|
126
281
|
*/
|
|
@@ -138,14 +293,20 @@ function buildGeminiEnv(options) {
|
|
|
138
293
|
// Resolve userId from config or auth token
|
|
139
294
|
const ekkosConfig = (0, state_1.getConfig)();
|
|
140
295
|
let userId = ekkosConfig?.userId || 'anonymous';
|
|
296
|
+
const authToken = (0, state_1.getAuthToken)();
|
|
141
297
|
if (userId === 'anonymous') {
|
|
142
|
-
const authToken = (0, state_1.getAuthToken)();
|
|
143
298
|
if (authToken?.startsWith('ekk_')) {
|
|
144
299
|
const parts = authToken.split('_');
|
|
145
300
|
if (parts.length >= 2 && parts[1])
|
|
146
301
|
userId = parts[1];
|
|
147
302
|
}
|
|
148
303
|
}
|
|
304
|
+
// Gemini CLI strictly requires an API key to exist in the environment,
|
|
305
|
+
// even when routing to a proxy. If they don't have GEMINI_API_KEY set,
|
|
306
|
+
// provide their GOOGLE_AI_API_KEY, ekkOS auth token, or a placeholder so the CLI boots up.
|
|
307
|
+
if (!env.GEMINI_API_KEY) {
|
|
308
|
+
env.GEMINI_API_KEY = env.GOOGLE_AI_API_KEY || authToken || 'ekk_guest_token_for_proxy';
|
|
309
|
+
}
|
|
149
310
|
// Build proxy URL — no query params (SDK concatenates baseUrl + path as strings).
|
|
150
311
|
// Format: /gproxy/{userId}/{session}/{sid}/{project64}
|
|
151
312
|
// Proxy extracts params from path, then routes /v1beta or /v1beta1 to googleRouter.
|
|
@@ -164,6 +325,11 @@ function buildGeminiEnv(options) {
|
|
|
164
325
|
*/
|
|
165
326
|
async function gemini(options = {}) {
|
|
166
327
|
(0, state_1.ensureEkkosDir)();
|
|
328
|
+
const extraArgs = extractGeminiCliArgs();
|
|
329
|
+
if (options.dashboard) {
|
|
330
|
+
if (launchGeminiWithDashboard(options, extraArgs))
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
167
333
|
console.log('');
|
|
168
334
|
console.log(chalk_1.default.cyan(' 🧠 ekkOS_') + chalk_1.default.gray(' + ') + chalk_1.default.blue('Gemini CLI'));
|
|
169
335
|
// Resolve binary
|
|
@@ -176,7 +342,9 @@ async function gemini(options = {}) {
|
|
|
176
342
|
// Register session for multi-session awareness
|
|
177
343
|
const sessionId = cliSessionId || crypto.randomUUID();
|
|
178
344
|
const sessionName = cliSessionName || 'gemini-session';
|
|
179
|
-
|
|
345
|
+
const metadata = getGeminiSessionMetadata(options, extraArgs);
|
|
346
|
+
(0, state_1.registerActiveSession)(sessionId, sessionName, process.cwd(), metadata);
|
|
347
|
+
writeGeminiSessionFiles(sessionId, sessionName, metadata);
|
|
180
348
|
if (!options.noProxy) {
|
|
181
349
|
await showPulseLoadedBanner();
|
|
182
350
|
}
|
|
@@ -184,14 +352,6 @@ async function gemini(options = {}) {
|
|
|
184
352
|
// Extract any trailing arguments meant for the inner CLI
|
|
185
353
|
// We look for 'gemini' in process.argv and pass everything after it.
|
|
186
354
|
// If the user did `ekkos gemini -m gemini-3.1-pro-preview`, we want to pass `-m gemini-3.1-pro-preview`
|
|
187
|
-
let extraArgs = [];
|
|
188
|
-
const geminiIdx = process.argv.indexOf('gemini');
|
|
189
|
-
if (geminiIdx !== -1) {
|
|
190
|
-
extraArgs = process.argv.slice(geminiIdx + 1).filter(a => {
|
|
191
|
-
// Filter out ekkos wrapper options
|
|
192
|
-
return !['--skip-proxy', '-v', '--verbose'].includes(a) && !(a === '-s' || a === '--session' || process.argv[process.argv.indexOf(a) - 1] === '-s' || process.argv[process.argv.indexOf(a) - 1] === '--session');
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
355
|
// Spawn Gemini CLI — stdio: inherit for full terminal passthrough
|
|
196
356
|
const child = (0, child_process_1.spawn)(geminiPath, extraArgs, {
|
|
197
357
|
stdio: 'inherit',
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.initLivingDocs = initLivingDocs;
|
|
7
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
8
|
+
const path_1 = require("path");
|
|
9
|
+
const living_docs_manager_js_1 = require("../local/living-docs-manager.js");
|
|
10
|
+
const state_1 = require("../utils/state");
|
|
11
|
+
const platform_js_1 = require("../utils/platform.js");
|
|
12
|
+
async function initLivingDocs(options) {
|
|
13
|
+
const targetPath = (0, path_1.resolve)(options.path || process.cwd());
|
|
14
|
+
const timeZone = options.timeZone || process.env.EKKOS_USER_TIMEZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
15
|
+
const apiKey = options.noSeed ? null : (0, state_1.getAuthToken)();
|
|
16
|
+
const apiUrl = options.noSeed ? undefined : (process.env.EKKOS_API_URL || platform_js_1.MCP_API_URL);
|
|
17
|
+
console.log('\n ' + chalk_1.default.cyan.bold('Initialize ekkOS Cortex'));
|
|
18
|
+
console.log(chalk_1.default.gray(' ─────────────────────────────────'));
|
|
19
|
+
console.log(chalk_1.default.gray(` Path: ${targetPath}`));
|
|
20
|
+
const manager = new living_docs_manager_js_1.LocalLivingDocsManager({
|
|
21
|
+
targetPath,
|
|
22
|
+
apiUrl,
|
|
23
|
+
apiKey,
|
|
24
|
+
timeZone,
|
|
25
|
+
onLog: message => console.log(chalk_1.default.gray(` ${message}`)),
|
|
26
|
+
});
|
|
27
|
+
// Force a full scan and compile by ignoring the cooldown
|
|
28
|
+
// We'll use a slightly different flow than .start() to make it one-shot
|
|
29
|
+
console.log(chalk_1.default.gray(' Scanning repository structure...'));
|
|
30
|
+
// We hack the cooldown by temporarily setting the env var if we could,
|
|
31
|
+
// but better to just call the internal methods if they were public.
|
|
32
|
+
// Since they are private, we'll use a small trick:
|
|
33
|
+
// The start() method is what we want, but we want it to exit after the first pass.
|
|
34
|
+
// Actually, let's just use start() and wait for the initialCompilePromise
|
|
35
|
+
manager.start();
|
|
36
|
+
// Give it a moment to start the initial pass
|
|
37
|
+
const checkInterval = setInterval(() => {
|
|
38
|
+
// @ts-ignore - reaching into private for the promise
|
|
39
|
+
if (manager.initialCompilePromise) {
|
|
40
|
+
// @ts-ignore
|
|
41
|
+
manager.initialCompilePromise.then(() => {
|
|
42
|
+
clearInterval(checkInterval);
|
|
43
|
+
manager.stop();
|
|
44
|
+
console.log('\n' + chalk_1.default.green(' ✓ ekkOS Cortex initialized successfully!'));
|
|
45
|
+
console.log(chalk_1.default.gray(' Your project now has ekkOS_CONTEXT.md files for every discovered system.'));
|
|
46
|
+
console.log(chalk_1.default.gray(' Run `ekkos cortex watch` to keep them updated while you work.'));
|
|
47
|
+
console.log('');
|
|
48
|
+
process.exit(0);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}, 100);
|
|
52
|
+
// Safety timeout
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
console.log(chalk_1.default.red('\n Initialization timed out.'));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}, 60000);
|
|
57
|
+
}
|
|
@@ -11,13 +11,13 @@ const state_1 = require("../utils/state");
|
|
|
11
11
|
const platform_js_1 = require("../utils/platform.js");
|
|
12
12
|
function printStartupSummary(options) {
|
|
13
13
|
console.log('');
|
|
14
|
-
console.log(chalk_1.default.cyan.bold(' ekkOS
|
|
15
|
-
console.log(chalk_1.default.gray('
|
|
14
|
+
console.log(chalk_1.default.cyan.bold(' ekkOS Cortex Watcher'));
|
|
15
|
+
console.log(chalk_1.default.gray(' ──────────────────────'));
|
|
16
16
|
console.log(chalk_1.default.gray(` Path: ${options.targetPath}`));
|
|
17
17
|
console.log(chalk_1.default.gray(` Timezone: ${options.timeZone}`));
|
|
18
18
|
console.log(chalk_1.default.gray(` Registry seed: ${options.seedingEnabled ? 'enabled' : 'disabled'}`));
|
|
19
19
|
console.log('');
|
|
20
|
-
console.log(chalk_1.default.gray(' Watching local files and
|
|
20
|
+
console.log(chalk_1.default.gray(' Watching local files and updating Cortex docs (ekkOS_CONTEXT.md) on change.'));
|
|
21
21
|
console.log(chalk_1.default.gray(' Press Ctrl+C to stop.'));
|
|
22
22
|
console.log('');
|
|
23
23
|
}
|
package/dist/commands/run.js
CHANGED
|
@@ -86,7 +86,7 @@ function getConfig(options) {
|
|
|
86
86
|
};
|
|
87
87
|
/* eslint-enable no-restricted-syntax */
|
|
88
88
|
}
|
|
89
|
-
const MAX_OUTPUT_1M_MODELS = '
|
|
89
|
+
const MAX_OUTPUT_1M_MODELS = '64000';
|
|
90
90
|
const MAX_OUTPUT_200K_OPUS_SONNET = '32768';
|
|
91
91
|
let runtimeClaudeCodeVersion = null;
|
|
92
92
|
let runtimeClaudeContextWindow = 'auto';
|
|
@@ -149,6 +149,62 @@ function buildClaudeLaunchModelArg(model, contextWindow) {
|
|
|
149
149
|
}
|
|
150
150
|
return normalized;
|
|
151
151
|
}
|
|
152
|
+
function buildClaudeLaunchProfileId(model, contextWindow) {
|
|
153
|
+
const normalized = normalizeRequestedLaunchModel(model).model;
|
|
154
|
+
if (!normalized)
|
|
155
|
+
return undefined;
|
|
156
|
+
const lower = normalized.toLowerCase();
|
|
157
|
+
if (/^claude-opus-4-6(?:$|-)/.test(lower)) {
|
|
158
|
+
if (contextWindow === '200k')
|
|
159
|
+
return 'claude-opus-4-6-200k';
|
|
160
|
+
if (contextWindow === '1m')
|
|
161
|
+
return 'claude-opus-4-6-1m';
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
if (/^claude-sonnet-4-6(?:$|-)/.test(lower)) {
|
|
165
|
+
if (contextWindow === '200k')
|
|
166
|
+
return 'claude-sonnet-4-6-200k';
|
|
167
|
+
if (contextWindow === '1m')
|
|
168
|
+
return 'claude-sonnet-4-6-1m';
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
if (/^claude-opus-4-5(?:$|-)/.test(lower))
|
|
172
|
+
return 'claude-opus-4-5';
|
|
173
|
+
if (/^claude-sonnet-4-5(?:$|-)/.test(lower))
|
|
174
|
+
return 'claude-sonnet-4-5';
|
|
175
|
+
if (/^claude-haiku-4-5(?:$|-)/.test(lower))
|
|
176
|
+
return 'claude-haiku-4-5';
|
|
177
|
+
return normalized;
|
|
178
|
+
}
|
|
179
|
+
function resolveClaudeContextWindowSize(model, contextWindow) {
|
|
180
|
+
if (contextWindow === '200k')
|
|
181
|
+
return 200000;
|
|
182
|
+
if (contextWindow === '1m' && modelSupportsOneMillionContext(model))
|
|
183
|
+
return 1000000;
|
|
184
|
+
if (model && !modelSupportsOneMillionContext(model))
|
|
185
|
+
return 200000;
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
function getClaudeSessionMetadata(options) {
|
|
189
|
+
const normalizedContextWindow = normalizeContextWindowOption(options.contextWindow);
|
|
190
|
+
const selectedModel = typeof options.model === 'string' ? options.model : undefined;
|
|
191
|
+
const launchModel = buildClaudeLaunchModelArg(selectedModel, normalizedContextWindow);
|
|
192
|
+
const profileId = buildClaudeLaunchProfileId(selectedModel, normalizedContextWindow);
|
|
193
|
+
const contextSize = resolveClaudeContextWindowSize(selectedModel, normalizedContextWindow);
|
|
194
|
+
const maxOutputTokens = Number.parseInt(resolveClaudeMaxOutputTokens(selectedModel, normalizedContextWindow), 10);
|
|
195
|
+
return {
|
|
196
|
+
provider: 'claude',
|
|
197
|
+
claudeModel: normalizeRequestedLaunchModel(selectedModel).model,
|
|
198
|
+
claudeLaunchModel: launchModel,
|
|
199
|
+
claudeProfile: profileId,
|
|
200
|
+
claudeContextWindow: normalizedContextWindow,
|
|
201
|
+
claudeContextSize: contextSize,
|
|
202
|
+
claudeMaxOutputTokens: Number.isFinite(maxOutputTokens) ? maxOutputTokens : undefined,
|
|
203
|
+
claudeCodeVersion: runtimeClaudeCodeVersion || undefined,
|
|
204
|
+
dashboardEnabled: options.dashboard === true,
|
|
205
|
+
bypassEnabled: options.bypass === true,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
152
208
|
function isTwoHundredKFixedOutputModel(model) {
|
|
153
209
|
const normalized = (model || '').trim().toLowerCase();
|
|
154
210
|
if (!normalized)
|
|
@@ -184,7 +240,7 @@ function renderClaudeLaunchSelectorIntro() {
|
|
|
184
240
|
console.log(line(chalk_1.default.white.bold('ekkOS.dev // PULSE')));
|
|
185
241
|
console.log(neonCyan('╠══════════════════════════════════════════════════════════════════════╣'));
|
|
186
242
|
console.log(line(`${acidGreen('200K')} ${steel('compat lane')} ${chalk_1.default.white('// Opus/Sonnet 4.5/4.6 => 32,768 output')}`));
|
|
187
|
-
console.log(line(`${signalAmber(' 1M')} ${steel('wide lane')} ${chalk_1.default.white('// Opus/Sonnet 4.6 =>
|
|
243
|
+
console.log(line(`${signalAmber(' 1M')} ${steel('wide lane')} ${chalk_1.default.white('// Opus/Sonnet 4.6 => 64,000 output')}`));
|
|
188
244
|
console.log(line(steel('Pick a launch vector, then a fixed runtime profile. No hidden context prompt.')));
|
|
189
245
|
console.log(neonCyan('╚══════════════════════════════════════════════════════════════════════╝'));
|
|
190
246
|
console.log('');
|
|
@@ -223,7 +279,7 @@ function buildLaunchModelChoices() {
|
|
|
223
279
|
value: 'claude-opus-4-6-200k',
|
|
224
280
|
},
|
|
225
281
|
{
|
|
226
|
-
name: `${cyan.bold('Claude Opus 4.6')} ${amber('[1M]')} ${chalk_1.default.gray('// out
|
|
282
|
+
name: `${cyan.bold('Claude Opus 4.6')} ${amber('[1M]')} ${chalk_1.default.gray('// out 64,000')}`,
|
|
227
283
|
value: 'claude-opus-4-6-1m',
|
|
228
284
|
},
|
|
229
285
|
{
|
|
@@ -235,7 +291,7 @@ function buildLaunchModelChoices() {
|
|
|
235
291
|
value: 'claude-sonnet-4-6-200k',
|
|
236
292
|
},
|
|
237
293
|
{
|
|
238
|
-
name: `${cyan.bold('Claude Sonnet 4.6')} ${amber('[1M]')} ${chalk_1.default.gray('// out
|
|
294
|
+
name: `${cyan.bold('Claude Sonnet 4.6')} ${amber('[1M]')} ${chalk_1.default.gray('// out 64,000')}`,
|
|
239
295
|
value: 'claude-sonnet-4-6-1m',
|
|
240
296
|
},
|
|
241
297
|
{
|
|
@@ -615,6 +671,7 @@ function getEkkosEnv() {
|
|
|
615
671
|
// Gateway extracts from URL: /proxy/{userId}/{sessionName}/v1/messages
|
|
616
672
|
const proxyUrl = (0, proxy_url_1.buildProxyUrl)(userId, cliSessionName, process.cwd(), cliSessionId, {
|
|
617
673
|
claudeCodeVersion: runtimeClaudeCodeVersion || undefined,
|
|
674
|
+
claudeProfile: buildClaudeLaunchProfileId(runtimeClaudeLaunchModel, runtimeClaudeContextWindow),
|
|
618
675
|
claudeContextWindow: runtimeClaudeContextWindow,
|
|
619
676
|
});
|
|
620
677
|
env.ANTHROPIC_BASE_URL = proxyUrl;
|
|
@@ -859,7 +916,7 @@ function formatPulseContextLine(contextWindow) {
|
|
|
859
916
|
return '200K // 200,000 token window // forced 32,768 output';
|
|
860
917
|
}
|
|
861
918
|
if (contextWindow === '1m') {
|
|
862
|
-
return '1M // 1,000,000 token window //
|
|
919
|
+
return '1M // 1,000,000 token window // safe 64,000 output cap';
|
|
863
920
|
}
|
|
864
921
|
return 'AUTO // ekkOS resolves 200K vs 1M from the selected profile';
|
|
865
922
|
}
|
|
@@ -906,7 +963,7 @@ async function showPulseLaunchLoader(options) {
|
|
|
906
963
|
console.log(buildPanelLine(steel, 'PATH', 'selector -> dashboard -> claude -> proxy'));
|
|
907
964
|
console.log(cyan(' ╚══════════════════════════════════════════════════════════════════════════════╝'));
|
|
908
965
|
console.log(` ${green('200K')} ${steel('// Opus/Sonnet 4.5/4.6 => 32,768 output')}`);
|
|
909
|
-
console.log(` ${amber('1M ')} ${steel('// Opus/Sonnet 4.6 =>
|
|
966
|
+
console.log(` ${amber('1M ')} ${steel('// Opus/Sonnet 4.6 => 64,000 output')}`);
|
|
910
967
|
console.log('');
|
|
911
968
|
for (const stage of stages) {
|
|
912
969
|
for (let filled = 0; filled <= barWidth; filled += 1) {
|
|
@@ -1131,10 +1188,16 @@ function cleanupInstanceFile(instanceId) {
|
|
|
1131
1188
|
* Keeps ~/.ekkos/current-session.json, ~/.claude/state/current-session.json,
|
|
1132
1189
|
* and ~/.ekkos/session-hint.json in sync whenever the session is known.
|
|
1133
1190
|
*/
|
|
1134
|
-
function writeSessionFiles(sessionId, sessionName) {
|
|
1191
|
+
function writeSessionFiles(sessionId, sessionName, metadata) {
|
|
1135
1192
|
try {
|
|
1136
1193
|
const ekkosDir = path.join(os.homedir(), '.ekkos');
|
|
1137
1194
|
const claudeStateDir = path.join(os.homedir(), '.claude', 'state');
|
|
1195
|
+
const launchMetadata = metadata || getClaudeSessionMetadata({
|
|
1196
|
+
model: runtimeClaudeLaunchModel,
|
|
1197
|
+
contextWindow: runtimeClaudeContextWindow,
|
|
1198
|
+
dashboard: false,
|
|
1199
|
+
bypass: false,
|
|
1200
|
+
});
|
|
1138
1201
|
// Ensure directories exist
|
|
1139
1202
|
if (!fs.existsSync(ekkosDir))
|
|
1140
1203
|
fs.mkdirSync(ekkosDir, { recursive: true });
|
|
@@ -1142,16 +1205,17 @@ function writeSessionFiles(sessionId, sessionName) {
|
|
|
1142
1205
|
fs.mkdirSync(claudeStateDir, { recursive: true });
|
|
1143
1206
|
const now = new Date().toISOString();
|
|
1144
1207
|
// 1. ~/.ekkos/current-session.json
|
|
1145
|
-
fs.writeFileSync(path.join(ekkosDir, 'current-session.json'), JSON.stringify({ session_id: sessionId, session_name: sessionName, timestamp: now }, null, 2));
|
|
1208
|
+
fs.writeFileSync(path.join(ekkosDir, 'current-session.json'), JSON.stringify({ session_id: sessionId, session_name: sessionName, timestamp: now, ...launchMetadata }, null, 2));
|
|
1146
1209
|
// 2. ~/.claude/state/current-session.json
|
|
1147
|
-
fs.writeFileSync(path.join(claudeStateDir, 'current-session.json'), JSON.stringify({ session_id: sessionId, session_name: sessionName, timestamp: now }, null, 2));
|
|
1210
|
+
fs.writeFileSync(path.join(claudeStateDir, 'current-session.json'), JSON.stringify({ session_id: sessionId, session_name: sessionName, timestamp: now, ...launchMetadata }, null, 2));
|
|
1148
1211
|
// 3. ~/.ekkos/session-hint.json (dashboard discovery)
|
|
1149
1212
|
fs.writeFileSync(path.join(ekkosDir, 'session-hint.json'), JSON.stringify({
|
|
1150
1213
|
session_name: sessionName,
|
|
1151
1214
|
session_id: sessionId,
|
|
1152
1215
|
project_path: process.cwd(),
|
|
1153
1216
|
timestamp: now,
|
|
1154
|
-
pid: process.pid
|
|
1217
|
+
pid: process.pid,
|
|
1218
|
+
...launchMetadata,
|
|
1155
1219
|
}, null, 2));
|
|
1156
1220
|
}
|
|
1157
1221
|
catch {
|
|
@@ -1199,7 +1263,7 @@ function launchWithDashboard(options) {
|
|
|
1199
1263
|
const termCols = process.stdout.columns ?? 160;
|
|
1200
1264
|
const termRows = process.stdout.rows ?? 48;
|
|
1201
1265
|
// Write session hint immediately so dashboard can find JSONL as it appears
|
|
1202
|
-
writeSessionFiles(crypto.randomUUID(), sessionName);
|
|
1266
|
+
writeSessionFiles(crypto.randomUUID(), sessionName, getClaudeSessionMetadata(options));
|
|
1203
1267
|
const runCommand = `EKKOS_NO_SPLASH=1 node "${ekkosCmd}" ${runArgs.join(' ')}`;
|
|
1204
1268
|
// Pass session name directly — dashboard starts rendering immediately (lazy JSONL resolution)
|
|
1205
1269
|
const dashCommand = `node "${ekkosCmd}" dashboard "${sessionName}" --refresh 2000`;
|
|
@@ -1263,7 +1327,7 @@ function launchWithWindowsTerminal(options) {
|
|
|
1263
1327
|
// Pre-generate session name so dashboard can start immediately
|
|
1264
1328
|
const sessionName = options.session || (0, state_1.uuidToWords)(crypto.randomUUID());
|
|
1265
1329
|
// Write session hint immediately
|
|
1266
|
-
writeSessionFiles(crypto.randomUUID(), sessionName);
|
|
1330
|
+
writeSessionFiles(crypto.randomUUID(), sessionName, getClaudeSessionMetadata(options));
|
|
1267
1331
|
// Build ekkos run args WITHOUT --dashboard (prevent recursion)
|
|
1268
1332
|
// Always pass -s so run reuses the same session name we gave the dashboard
|
|
1269
1333
|
const runArgs = ['run', '-s', sessionName];
|
|
@@ -1778,8 +1842,8 @@ async function run(options) {
|
|
|
1778
1842
|
// ════════════════════════════════════════════════════════════════════════════
|
|
1779
1843
|
const initialSessionId = cliSessionId || 'pending';
|
|
1780
1844
|
const initialSessionName = currentSession || 'initializing';
|
|
1781
|
-
(0, state_1.registerActiveSession)(initialSessionId, initialSessionName, process.cwd());
|
|
1782
|
-
writeSessionFiles(initialSessionId, initialSessionName);
|
|
1845
|
+
(0, state_1.registerActiveSession)(initialSessionId, initialSessionName, process.cwd(), getClaudeSessionMetadata(options));
|
|
1846
|
+
writeSessionFiles(initialSessionId, initialSessionName, getClaudeSessionMetadata(options));
|
|
1783
1847
|
dlog(`Registered active session (PID ${process.pid})`);
|
|
1784
1848
|
// Show active sessions count if verbose
|
|
1785
1849
|
if (verbose) {
|
|
@@ -1946,9 +2010,9 @@ async function run(options) {
|
|
|
1946
2010
|
// and shown in "Continuum Loaded". Re-deriving from JSONL UUID produces a
|
|
1947
2011
|
// different name since Claude Code's UUID ≠ the CLI-generated UUID.
|
|
1948
2012
|
currentSession = cliSessionName || (0, state_1.uuidToWords)(sessionId);
|
|
1949
|
-
(0, state_1.updateCurrentProcessSession)(currentSessionId, currentSession);
|
|
2013
|
+
(0, state_1.updateCurrentProcessSession)(currentSessionId, currentSession, getClaudeSessionMetadata(options));
|
|
1950
2014
|
(0, state_1.updateState)({ sessionId: currentSessionId, sessionName: currentSession });
|
|
1951
|
-
writeSessionFiles(currentSessionId, currentSession);
|
|
2015
|
+
writeSessionFiles(currentSessionId, currentSession, getClaudeSessionMetadata(options));
|
|
1952
2016
|
bindRealSessionToProxy(currentSession, 'fast-transcript', currentSessionId);
|
|
1953
2017
|
dlog(`[TRANSCRIPT] FAST DETECT: New transcript found! ${fullPath}`);
|
|
1954
2018
|
evictionDebugLog('TRANSCRIPT_SET', 'Fast poll detected new file', {
|
|
@@ -2842,10 +2906,10 @@ async function run(options) {
|
|
|
2842
2906
|
// Keep cliSessionName if set (proxy mode) — JSONL UUID differs from CLI UUID
|
|
2843
2907
|
currentSession = cliSessionName || (0, state_1.uuidToWords)(currentSessionId);
|
|
2844
2908
|
// Update THIS process's session entry (not global state.json)
|
|
2845
|
-
(0, state_1.updateCurrentProcessSession)(currentSessionId, currentSession);
|
|
2909
|
+
(0, state_1.updateCurrentProcessSession)(currentSessionId, currentSession, getClaudeSessionMetadata(options));
|
|
2846
2910
|
// Also update global state for backwards compatibility
|
|
2847
2911
|
(0, state_1.updateState)({ sessionId: currentSessionId, sessionName: currentSession });
|
|
2848
|
-
writeSessionFiles(currentSessionId, currentSession);
|
|
2912
|
+
writeSessionFiles(currentSessionId, currentSession, getClaudeSessionMetadata(options));
|
|
2849
2913
|
dlog(`Session detected from UUID: ${currentSession}`);
|
|
2850
2914
|
resolveTranscriptFromSessionId('session-id-from-output');
|
|
2851
2915
|
bindRealSessionToProxy(currentSession, 'session-id-from-output', currentSessionId || undefined);
|
|
@@ -2878,10 +2942,10 @@ async function run(options) {
|
|
|
2878
2942
|
currentSession = lastSeenSessionName;
|
|
2879
2943
|
observedSessionThisRun = true; // Mark that we've seen a session in THIS process
|
|
2880
2944
|
// Update THIS process's session entry (not global state.json)
|
|
2881
|
-
(0, state_1.updateCurrentProcessSession)(currentSessionId || 'unknown', currentSession);
|
|
2945
|
+
(0, state_1.updateCurrentProcessSession)(currentSessionId || 'unknown', currentSession, getClaudeSessionMetadata(options));
|
|
2882
2946
|
// Also update global state for backwards compatibility
|
|
2883
2947
|
(0, state_1.updateState)({ sessionName: currentSession });
|
|
2884
|
-
writeSessionFiles(currentSessionId || 'unknown', currentSession);
|
|
2948
|
+
writeSessionFiles(currentSessionId || 'unknown', currentSession, getClaudeSessionMetadata(options));
|
|
2885
2949
|
dlog(`Session detected from status line: ${currentSession} (observedSessionThisRun=true)`);
|
|
2886
2950
|
bindRealSessionToProxy(currentSession, 'status-line', currentSessionId || undefined);
|
|
2887
2951
|
resolveTranscriptFromSessionId('status-line');
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.setupCiCommand = setupCiCommand;
|
|
40
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const WORKFLOW_CONTENT = `name: ekkOS Cortex Validation
|
|
44
|
+
|
|
45
|
+
on:
|
|
46
|
+
push:
|
|
47
|
+
branches: [main, master]
|
|
48
|
+
paths:
|
|
49
|
+
- '**/ekkOS_CONTEXT.md'
|
|
50
|
+
pull_request:
|
|
51
|
+
branches: [main, master]
|
|
52
|
+
paths:
|
|
53
|
+
- '**/ekkOS_CONTEXT.md'
|
|
54
|
+
|
|
55
|
+
jobs:
|
|
56
|
+
validate-cortex:
|
|
57
|
+
name: Validate Cortex Docs
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
timeout-minutes: 10
|
|
60
|
+
|
|
61
|
+
steps:
|
|
62
|
+
- name: Checkout repository
|
|
63
|
+
uses: actions/checkout@v4
|
|
64
|
+
|
|
65
|
+
- name: Setup Node.js
|
|
66
|
+
uses: actions/setup-node@v4
|
|
67
|
+
with:
|
|
68
|
+
node-version: '20'
|
|
69
|
+
|
|
70
|
+
- name: Validate Cortex docs
|
|
71
|
+
run: npx @google/gemini-cli docs validate
|
|
72
|
+
|
|
73
|
+
- name: Add validation summary
|
|
74
|
+
if: always()
|
|
75
|
+
run: |
|
|
76
|
+
echo "### ekkOS Cortex Validation" >> "$GITHUB_STEP_SUMMARY"
|
|
77
|
+
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
78
|
+
echo "Validated \`ekkOS_CONTEXT.md\` files across the repository." >> "$GITHUB_STEP_SUMMARY"
|
|
79
|
+
if [ "\${{ job.status }}" = "success" ]; then
|
|
80
|
+
echo "**Result:** All Cortex checks passed." >> "$GITHUB_STEP_SUMMARY"
|
|
81
|
+
else
|
|
82
|
+
echo "**Result:** Some Cortex checks failed. See logs for details." >> "$GITHUB_STEP_SUMMARY"
|
|
83
|
+
fi
|
|
84
|
+
`;
|
|
85
|
+
async function setupCiCommand(options) {
|
|
86
|
+
const repoRoot = options.repoRoot || process.cwd();
|
|
87
|
+
const workflowsDir = path.join(repoRoot, '.github', 'workflows');
|
|
88
|
+
const workflowFile = path.join(workflowsDir, 'ekkos-cortex-validation.yml');
|
|
89
|
+
console.log(chalk_1.default.cyan.bold('\n ekkOS Cortex CI/CD Setup'));
|
|
90
|
+
console.log(chalk_1.default.gray(' ─────────────────────────'));
|
|
91
|
+
if (!fs.existsSync(workflowsDir)) {
|
|
92
|
+
fs.mkdirSync(workflowsDir, { recursive: true });
|
|
93
|
+
console.log(chalk_1.default.gray(` Created directory: .github/workflows`));
|
|
94
|
+
}
|
|
95
|
+
if (fs.existsSync(workflowFile)) {
|
|
96
|
+
console.log(chalk_1.default.yellow(` ⚠️ Workflow file already exists at: .github/workflows/ekkos-cortex-validation.yml`));
|
|
97
|
+
console.log(chalk_1.default.gray(` Skipping creation to avoid overwriting.`));
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
fs.writeFileSync(workflowFile, WORKFLOW_CONTENT, 'utf-8');
|
|
101
|
+
console.log(chalk_1.default.green(` ✓ Created GitHub Actions workflow: .github/workflows/ekkos-cortex-validation.yml`));
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log(chalk_1.default.gray(' This workflow will automatically run `ekkos docs validate` on pull requests'));
|
|
104
|
+
console.log(chalk_1.default.gray(' and pushes that modify any ekkOS_CONTEXT.md (Cortex) file in your repository.'));
|
|
105
|
+
console.log('');
|
|
106
|
+
return 0;
|
|
107
|
+
}
|