@jellyos/agent 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/ExtensionAPI.d.ts +5 -0
- package/dist/cli.js +15 -3
- package/dist/loader.d.ts +2 -9
- package/dist/loader.js +2 -1
- package/dist/tui/App.d.ts +2 -1
- package/dist/tui/App.js +68 -4
- package/dist/tui/ModelSelector.d.ts +22 -0
- package/dist/tui/ModelSelector.js +86 -0
- package/package.json +2 -3
- package/dist/tests/ContextStore.test.d.ts +0 -2
- package/dist/tests/ContextStore.test.js +0 -74
- package/dist/tests/ModelRegistry.test.d.ts +0 -2
- package/dist/tests/ModelRegistry.test.js +0 -69
- package/dist/tests/SessionManager.test.d.ts +0 -2
- package/dist/tests/SessionManager.test.js +0 -108
- package/dist/tests/TechnicalAnalysis.test.d.ts +0 -2
- package/dist/tests/TechnicalAnalysis.test.js +0 -109
|
@@ -60,6 +60,11 @@ export interface UIContext {
|
|
|
60
60
|
* Pi compat — accepted but engine renders its own Ink header.
|
|
61
61
|
*/
|
|
62
62
|
setHeader(factory: HeaderFactory): void;
|
|
63
|
+
/**
|
|
64
|
+
* Open the interactive model selector overlay.
|
|
65
|
+
* @param query Optional initial search query to pre-filter the list.
|
|
66
|
+
*/
|
|
67
|
+
showModelSelector(query?: string): void;
|
|
63
68
|
theme: ThemeContext;
|
|
64
69
|
}
|
|
65
70
|
export interface CommandContext {
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* cli.ts — JellyOS entry point.
|
|
3
3
|
* Completely standalone — all outbound, no inbound ports exposed.
|
|
4
4
|
*/
|
|
5
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { resolve, join } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { render } from "ink";
|
|
@@ -80,6 +80,16 @@ function loadContext() {
|
|
|
80
80
|
return { effectLevel: "normal", chain: "ethereum" };
|
|
81
81
|
try {
|
|
82
82
|
const ctx = JSON.parse(readFileSync(ctxPath, "utf-8"));
|
|
83
|
+
if (ctx.model && typeof ctx.model === "string") {
|
|
84
|
+
process.env.DEFAULT_MODEL = ctx.model;
|
|
85
|
+
try {
|
|
86
|
+
const ef = join(JELLY_HOME, ".env");
|
|
87
|
+
const c = existsSync(ef) ? readFileSync(ef, "utf-8") : "";
|
|
88
|
+
const re = /^DEFAULT_MODEL=.*$/m;
|
|
89
|
+
writeFileSync(ef, re.test(c) ? c.replace(re, `DEFAULT_MODEL=${ctx.model}`) : c + `\nDEFAULT_MODEL=${ctx.model}\n`, "utf-8");
|
|
90
|
+
}
|
|
91
|
+
catch { /* non-fatal */ }
|
|
92
|
+
}
|
|
83
93
|
return { effectLevel: ctx.effect_level ?? "normal", chain: ctx.active_chain ?? "ethereum" };
|
|
84
94
|
}
|
|
85
95
|
catch {
|
|
@@ -114,6 +124,7 @@ if (headlessMsg) {
|
|
|
114
124
|
setStatus: () => { },
|
|
115
125
|
setTheme: () => { },
|
|
116
126
|
setHeader: () => { },
|
|
127
|
+
showModelSelector: () => { },
|
|
117
128
|
theme,
|
|
118
129
|
};
|
|
119
130
|
const sessionCtx = {
|
|
@@ -162,16 +173,16 @@ else {
|
|
|
162
173
|
console.log(T.muted(" Standalone AI trading agent — all local, zero exposure\n"));
|
|
163
174
|
const registry = new Registry();
|
|
164
175
|
const { effectLevel, chain } = loadContext();
|
|
165
|
-
// These callbacks are forwarded into the extension API so ui.setStatus
|
|
166
|
-
// and ui.notify work even during the session_start hook (before Ink mounts).
|
|
167
176
|
let _notifyFn = null;
|
|
168
177
|
let _setStatusFn = null;
|
|
178
|
+
let _showModelSelectorFn = null;
|
|
169
179
|
if (extensionPath) {
|
|
170
180
|
try {
|
|
171
181
|
console.log(T.muted(` Loading: ${extensionPath}`));
|
|
172
182
|
await loadExtension(extensionPath, registry, {
|
|
173
183
|
onNotify: (msg) => { _notifyFn?.(msg); },
|
|
174
184
|
onStatusUpdate: (k, v) => { _setStatusFn?.(k, v); },
|
|
185
|
+
onShowModelSelector: (q) => { _showModelSelectorFn?.(q); },
|
|
175
186
|
});
|
|
176
187
|
console.log(T.success(` ✓ ${registry.listTools().length} tools · ${registry.listCommands().length} commands`));
|
|
177
188
|
}
|
|
@@ -203,6 +214,7 @@ else {
|
|
|
203
214
|
costTracker,
|
|
204
215
|
onNotifyReady: (fn) => { _notifyFn = fn; },
|
|
205
216
|
onStatusReady: (fn) => { _setStatusFn = fn; },
|
|
217
|
+
onModelSelectorReady: (fn) => { _showModelSelectorFn = fn; },
|
|
206
218
|
}), { exitOnCtrlC: false });
|
|
207
219
|
})();
|
|
208
220
|
} // end headless else
|
package/dist/loader.d.ts
CHANGED
|
@@ -7,17 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { Registry } from "./api/Registry.js";
|
|
9
9
|
export interface LoaderOptions {
|
|
10
|
-
/**
|
|
11
|
-
* Called when the extension uses ui.setStatus(key, value).
|
|
12
|
-
* The App component passes a state-setter here so status badges update live.
|
|
13
|
-
*/
|
|
14
10
|
onStatusUpdate?: (key: string, value: string) => void;
|
|
15
|
-
/**
|
|
16
|
-
* Called when the extension calls ui.notify(message).
|
|
17
|
-
* Before the TUI is mounted, the App wires this to React state.
|
|
18
|
-
* We store the latest notifier here so the extension can call it any time.
|
|
19
|
-
*/
|
|
20
11
|
onNotify?: (message: string) => void;
|
|
12
|
+
/** Called when the extension calls ui.showModelSelector(query). */
|
|
13
|
+
onShowModelSelector?: (query?: string) => void;
|
|
21
14
|
}
|
|
22
15
|
export declare function loadExtension(extensionPath: string, registry: Registry, opts?: LoaderOptions): Promise<void>;
|
|
23
16
|
//# sourceMappingURL=loader.d.ts.map
|
package/dist/loader.js
CHANGED
|
@@ -15,14 +15,15 @@ export async function loadExtension(extensionPath, registry, opts = {}) {
|
|
|
15
15
|
throw new Error(`Extension file not found: ${abs}`);
|
|
16
16
|
}
|
|
17
17
|
const theme = makeTheme();
|
|
18
|
-
// Live-updateable notifier — App.tsx replaces this once mounted
|
|
19
18
|
let _notify = opts.onNotify ?? ((_msg) => { });
|
|
20
19
|
let _setStatus = opts.onStatusUpdate ?? ((_k, _v) => { });
|
|
20
|
+
let _showModelSelector = opts.onShowModelSelector ?? ((_q) => { });
|
|
21
21
|
const ui = {
|
|
22
22
|
notify(message) { _notify(message); },
|
|
23
23
|
setStatus(key, value) { _setStatus(key, value); },
|
|
24
24
|
setTheme(_name) { },
|
|
25
25
|
setHeader(_factory) { },
|
|
26
|
+
showModelSelector(query) { _showModelSelector(query); },
|
|
26
27
|
theme,
|
|
27
28
|
};
|
|
28
29
|
const api = {
|
package/dist/tui/App.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface AppProps {
|
|
|
15
15
|
costTracker?: CostTracker;
|
|
16
16
|
onNotifyReady?: (fn: (msg: string) => void) => void;
|
|
17
17
|
onStatusReady?: (fn: (key: string, val: string) => void) => void;
|
|
18
|
+
onModelSelectorReady?: (fn: (query?: string) => void) => void;
|
|
18
19
|
}
|
|
19
|
-
export declare function App({ registry, systemPrompt, effectLevel: initialEffect, chain: initialChain, modelReg, costTracker, onNotifyReady, onStatusReady, }: AppProps): import("react/jsx-runtime").JSX.Element;
|
|
20
|
+
export declare function App({ registry, systemPrompt, effectLevel: initialEffect, chain: initialChain, modelReg, costTracker, onNotifyReady, onStatusReady, onModelSelectorReady, }: AppProps): import("react/jsx-runtime").JSX.Element;
|
|
20
21
|
//# sourceMappingURL=App.d.ts.map
|
package/dist/tui/App.js
CHANGED
|
@@ -4,10 +4,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
* Multi-pane TUI with context bar, syntax-highlighted tool output,
|
|
5
5
|
* live side panel with ticker/prices, and command palette triggered via /palette.
|
|
6
6
|
*/
|
|
7
|
-
import { useState, useCallback, useEffect, useRef } from "react";
|
|
7
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
8
8
|
import { Box, Text, useApp, useInput } from "ink";
|
|
9
9
|
import { StatusBar } from "./StatusBar.js";
|
|
10
10
|
import { REPL } from "./REPL.js";
|
|
11
|
+
import { ModelSelector } from "./ModelSelector.js";
|
|
11
12
|
import { makeTheme, T, JELLY_COLORS } from "./theme.js";
|
|
12
13
|
import { AgentRunner } from "../runner/AgentRunner.js";
|
|
13
14
|
import { SessionManager } from "../session/SessionManager.js";
|
|
@@ -82,7 +83,7 @@ let _msgIdCounter = 0;
|
|
|
82
83
|
function nextId() { return String(++_msgIdCounter); }
|
|
83
84
|
// Unique session ID for memory persistence
|
|
84
85
|
const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
85
|
-
export function App({ registry, systemPrompt, effectLevel: initialEffect = "normal", chain: initialChain = "ethereum", modelReg, costTracker, onNotifyReady, onStatusReady, }) {
|
|
86
|
+
export function App({ registry, systemPrompt, effectLevel: initialEffect = "normal", chain: initialChain = "ethereum", modelReg, costTracker, onNotifyReady, onStatusReady, onModelSelectorReady, }) {
|
|
86
87
|
const { exit } = useApp();
|
|
87
88
|
const [messages, setMessages] = useState([]);
|
|
88
89
|
const [streaming, setStreaming] = useState("");
|
|
@@ -95,6 +96,9 @@ export function App({ registry, systemPrompt, effectLevel: initialEffect = "norm
|
|
|
95
96
|
const [ticker, setTicker] = useState("");
|
|
96
97
|
const [costBadge, setCostBadge] = useState("");
|
|
97
98
|
const [newsBadge, setNewsBadge] = useState("");
|
|
99
|
+
// ── Model selector overlay state ──────────────────────────────────────
|
|
100
|
+
const [showModelSelector, setShowModelSelector] = useState(false);
|
|
101
|
+
const [modelSelectorInitialQuery, setModelSelectorInitialQuery] = useState("");
|
|
98
102
|
const runnerRef = useRef(null);
|
|
99
103
|
const sessionRef = useRef(null);
|
|
100
104
|
const sessionCtxRef = useRef(null);
|
|
@@ -119,7 +123,16 @@ export function App({ registry, systemPrompt, effectLevel: initialEffect = "norm
|
|
|
119
123
|
if (key === "effect_level")
|
|
120
124
|
setEffectLevel(value);
|
|
121
125
|
}, []);
|
|
122
|
-
const uiCtx = {
|
|
126
|
+
const uiCtx = {
|
|
127
|
+
notify, setStatus,
|
|
128
|
+
setTheme(_n) { },
|
|
129
|
+
setHeader(_f) { },
|
|
130
|
+
showModelSelector: (query) => {
|
|
131
|
+
setModelSelectorInitialQuery(query ?? "");
|
|
132
|
+
setShowModelSelector(true);
|
|
133
|
+
},
|
|
134
|
+
theme,
|
|
135
|
+
};
|
|
123
136
|
// Register built-in tools
|
|
124
137
|
function registerBuiltinTools() {
|
|
125
138
|
if (!modelReg)
|
|
@@ -165,6 +178,13 @@ export function App({ registry, systemPrompt, effectLevel: initialEffect = "norm
|
|
|
165
178
|
} });
|
|
166
179
|
}
|
|
167
180
|
useEffect(() => { onNotifyReady?.(notify); onStatusReady?.(setStatus); }, []);
|
|
181
|
+
// ── Expose model selector trigger to extensions ──────────────────────────
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
onModelSelectorReady?.((initialQuery) => {
|
|
184
|
+
setModelSelectorInitialQuery(initialQuery ?? "");
|
|
185
|
+
setShowModelSelector(true);
|
|
186
|
+
});
|
|
187
|
+
}, [onModelSelectorReady]);
|
|
168
188
|
useEffect(() => {
|
|
169
189
|
registerBuiltinTools();
|
|
170
190
|
priceFeed.track("btc", "eth", "sol", "bnb", "matic", "arb", "op", "avax", "link", "uni", "doge", "xrp", "ada", "dot", "atom", "near", "sui", "apt", "pepe", "aave");
|
|
@@ -454,11 +474,55 @@ export function App({ registry, systemPrompt, effectLevel: initialEffect = "norm
|
|
|
454
474
|
notify(T.error(`Error: ${e.message}`));
|
|
455
475
|
}
|
|
456
476
|
}, [registry, exit, push, notify, uiCtx, modelReg, costTracker]);
|
|
477
|
+
// ── Handle model selection ───────────────────────────────────────────
|
|
478
|
+
const handleModelSelect = useCallback((modelId) => {
|
|
479
|
+
setShowModelSelector(false);
|
|
480
|
+
try {
|
|
481
|
+
const { writeFileSync, readFileSync, existsSync, mkdirSync } = require("node:fs");
|
|
482
|
+
const { join } = require("node:path");
|
|
483
|
+
const { homedir } = require("node:os");
|
|
484
|
+
const JELLY_HOME = process.env.JELLYOS_HOME ?? join(homedir(), ".jelly");
|
|
485
|
+
const envFile = join(JELLY_HOME, ".env");
|
|
486
|
+
mkdirSync(JELLY_HOME, { recursive: true });
|
|
487
|
+
const content = existsSync(envFile) ? readFileSync(envFile, "utf-8") : "";
|
|
488
|
+
const re = /^DEFAULT_MODEL=.*$/m;
|
|
489
|
+
const line = `DEFAULT_MODEL=${modelId}`;
|
|
490
|
+
writeFileSync(envFile, re.test(content) ? content.replace(re, line) : content + "\n" + line + "\n", "utf-8");
|
|
491
|
+
process.env.DEFAULT_MODEL = modelId;
|
|
492
|
+
const ctxPath = join(JELLY_HOME, "context.json");
|
|
493
|
+
const store = existsSync(ctxPath) ? JSON.parse(readFileSync(ctxPath, "utf-8")) : {};
|
|
494
|
+
store.model = modelId;
|
|
495
|
+
writeFileSync(ctxPath, JSON.stringify(store, null, 2), "utf-8");
|
|
496
|
+
}
|
|
497
|
+
catch { /* non-fatal */ }
|
|
498
|
+
notify(T.accent(`Model set to: ${modelId}\nRestart jellyos to apply.`));
|
|
499
|
+
}, [notify]);
|
|
500
|
+
const handleModelCancel = useCallback(() => {
|
|
501
|
+
setShowModelSelector(false);
|
|
502
|
+
}, []);
|
|
503
|
+
const modelList = useMemo(() => {
|
|
504
|
+
if (!modelReg)
|
|
505
|
+
return [];
|
|
506
|
+
const tiers = ["orchestrator", "analyst", "worker", "free"];
|
|
507
|
+
const items = [];
|
|
508
|
+
for (const tier of tiers) {
|
|
509
|
+
const pool = modelReg.getPool(tier);
|
|
510
|
+
for (const tm of pool) {
|
|
511
|
+
if (tm.available && tm.failures < 3) {
|
|
512
|
+
items.push({ id: tm.model.id, tier });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return items;
|
|
517
|
+
}, [modelReg]);
|
|
457
518
|
const ctx = getContextBar(sessionRef.current);
|
|
458
519
|
const statusLine = [ticker, costBadge, newsBadge, ...Object.values(statusBadges)].filter(Boolean).join(" ") || null;
|
|
520
|
+
// ── Overlay: model selector ────────────────────────────────────────────
|
|
521
|
+
if (showModelSelector) {
|
|
522
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(StatusBar, { model: modelName, chain: chain, vaultLocked: vaultLocked, effectLevel: effectLevel, toolRunning: toolRunning, connected: true, statusLine: statusLine }), _jsx(ModelSelector, { models: modelList, currentModelId: process.env.DEFAULT_MODEL ?? "", onSelect: handleModelSelect, onCancel: handleModelCancel, initialQuery: modelSelectorInitialQuery })] }));
|
|
523
|
+
}
|
|
459
524
|
// ── Multi-pane layout ────────────────────────────────────────────────────
|
|
460
525
|
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(StatusBar, { model: modelName, chain: chain, vaultLocked: vaultLocked, effectLevel: effectLevel, toolRunning: toolRunning, connected: true, statusLine: statusLine }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", width: 32, borderStyle: "single", borderColor: JELLY_COLORS.dim, paddingX: 1, flexShrink: 0, children: [_jsx(Text, { color: JELLY_COLORS.accent, bold: true, children: "\uD83D\uDCE1 Ticker" }), _jsx(Text, { color: JELLY_COLORS.muted, wrap: "truncate", children: ticker || "Loading…" }), _jsx(Text, { color: JELLY_COLORS.dim, children: "─".repeat(28) }), _jsxs(Text, { color: JELLY_COLORS.accent, children: ["Context ", ctx.turboReady ? "" : "⚠"] }), _jsxs(Text, { color: ctx.color, children: [ctx.bar, " ", ctx.pct, "%", ctx.turboReady ? "" : " no turbo"] }), _jsx(Text, { color: JELLY_COLORS.dim, children: "─".repeat(28) }), _jsx(Text, { color: JELLY_COLORS.accent, children: "Effect" }), _jsx(Text, { color: effectLevel === "eco" ? "#22c55e" : effectLevel === "turbo" ? "#f59e0b" : effectLevel === "max" ? "#ef4444" : JELLY_COLORS.accent, children: effectLevel.toUpperCase() }), _jsx(Text, { color: JELLY_COLORS.dim, children: "─".repeat(28) }), _jsx(Text, { color: JELLY_COLORS.accent, children: "News" }), _jsx(Text, { color: JELLY_COLORS.muted, wrap: "truncate", children: newsBadge || "…" })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(REPL, { messages: messages, streamingText: streaming, toolRunning: toolRunning, onSubmit: handleSubmit, disabled: disabled, onAbort: () => {
|
|
461
|
-
// #25: Escape aborts in-flight stream
|
|
462
526
|
runnerRef.current?.abort();
|
|
463
527
|
setDisabled(false);
|
|
464
528
|
setToolRunning(null);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelSelector — interactive model picker overlay.
|
|
3
|
+
* Renders a searchable, scrollable list of models with keyboard navigation.
|
|
4
|
+
*
|
|
5
|
+
* Up/Down: move selection
|
|
6
|
+
* Enter: select model
|
|
7
|
+
* Escape: cancel
|
|
8
|
+
* Type: filter models by name/provider
|
|
9
|
+
*/
|
|
10
|
+
export interface ModelItem {
|
|
11
|
+
id: string;
|
|
12
|
+
tier: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ModelSelectorProps {
|
|
15
|
+
models: ModelItem[];
|
|
16
|
+
currentModelId: string;
|
|
17
|
+
onSelect(modelId: string): void;
|
|
18
|
+
onCancel(): void;
|
|
19
|
+
initialQuery?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function ModelSelector({ models, currentModelId, onSelect, onCancel, initialQuery, }: ModelSelectorProps): import("react/jsx-runtime").JSX.Element;
|
|
22
|
+
//# sourceMappingURL=ModelSelector.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ModelSelector — interactive model picker overlay.
|
|
4
|
+
* Renders a searchable, scrollable list of models with keyboard navigation.
|
|
5
|
+
*
|
|
6
|
+
* Up/Down: move selection
|
|
7
|
+
* Enter: select model
|
|
8
|
+
* Escape: cancel
|
|
9
|
+
* Type: filter models by name/provider
|
|
10
|
+
*/
|
|
11
|
+
import { useState, useCallback, useEffect, useMemo } from "react";
|
|
12
|
+
import { Box, Text, useInput } from "ink";
|
|
13
|
+
import TextInput from "ink-text-input";
|
|
14
|
+
import { JELLY_COLORS, T } from "./theme.js";
|
|
15
|
+
const VISIBLE_COUNT = 10;
|
|
16
|
+
function fuzzyFilter(items, query) {
|
|
17
|
+
const q = query.toLowerCase().trim();
|
|
18
|
+
if (!q)
|
|
19
|
+
return items;
|
|
20
|
+
return items.filter((item) => {
|
|
21
|
+
const hay = `${item.id} ${item.tier}`.toLowerCase();
|
|
22
|
+
if (hay.includes(q))
|
|
23
|
+
return true;
|
|
24
|
+
let hi = 0;
|
|
25
|
+
for (let qi = 0; qi < q.length && hi < hay.length; hi++) {
|
|
26
|
+
if (hay[hi] === q[qi])
|
|
27
|
+
qi++;
|
|
28
|
+
}
|
|
29
|
+
return hi <= hay.length;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export function ModelSelector({ models, currentModelId, onSelect, onCancel, initialQuery = "", }) {
|
|
33
|
+
const [query, setQuery] = useState(initialQuery);
|
|
34
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
35
|
+
const filtered = useMemo(() => fuzzyFilter(models, query), [models, query]);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
setSelectedIndex((prev) => Math.min(prev, Math.max(0, filtered.length - 1)));
|
|
38
|
+
}, [filtered.length]);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const idx = models.findIndex((m) => m.id === currentModelId);
|
|
41
|
+
if (idx >= 0)
|
|
42
|
+
setSelectedIndex(idx);
|
|
43
|
+
}, [models, currentModelId]);
|
|
44
|
+
const handleSubmit = useCallback(() => {
|
|
45
|
+
const selected = filtered[selectedIndex];
|
|
46
|
+
if (selected)
|
|
47
|
+
onSelect(selected.id);
|
|
48
|
+
}, [filtered, selectedIndex, onSelect]);
|
|
49
|
+
useInput((input, key) => {
|
|
50
|
+
if (key.escape) {
|
|
51
|
+
onCancel();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (key.return) {
|
|
55
|
+
handleSubmit();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (key.upArrow) {
|
|
59
|
+
setSelectedIndex((prev) => (prev <= 0 ? filtered.length - 1 : prev - 1));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (key.downArrow) {
|
|
63
|
+
setSelectedIndex((prev) => (prev >= filtered.length - 1 ? 0 : prev + 1));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (key.backspace || key.delete) {
|
|
67
|
+
setQuery((prev) => prev.slice(0, -1));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!key.ctrl && !key.meta && input) {
|
|
71
|
+
setQuery((prev) => prev + input);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
const startIdx = Math.max(0, Math.min(selectedIndex - Math.floor(VISIBLE_COUNT / 2), filtered.length - VISIBLE_COUNT));
|
|
76
|
+
const endIdx = Math.min(startIdx + VISIBLE_COUNT, filtered.length);
|
|
77
|
+
const visible = filtered.slice(startIdx, endIdx);
|
|
78
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Box, { borderStyle: "round", borderColor: JELLY_COLORS.accent, marginY: 1, paddingX: 1, children: _jsx(Text, { color: JELLY_COLORS.accent, bold: true, children: "\uD83E\uDEBC Select Model" }) }), _jsxs(Box, { paddingX: 2, children: [_jsx(Text, { color: JELLY_COLORS.muted, children: " Search: " }), _jsx(TextInput, { value: query, onChange: setQuery, onSubmit: handleSubmit, placeholder: "type to filter..." })] }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: JELLY_COLORS.dim, children: " \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" }), filtered.length !== models.length && (_jsxs(Text, { color: JELLY_COLORS.muted, children: [" \u00B7 ", filtered.length, "/", models.length, " shown"] }))] }), _jsx(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, children: filtered.length === 0 ? (_jsxs(Text, { color: JELLY_COLORS.warn, children: [" No models match \"", query, "\""] })) : (visible.map((item, visIdx) => {
|
|
79
|
+
const realIdx = startIdx + visIdx;
|
|
80
|
+
const isSelected = realIdx === selectedIndex;
|
|
81
|
+
const isCurrent = item.id === currentModelId;
|
|
82
|
+
const checkMark = isCurrent ? T.success(" ✓") : "";
|
|
83
|
+
return (_jsxs(Text, { color: isSelected ? JELLY_COLORS.accent : undefined, children: [_jsx(Text, { color: JELLY_COLORS.accent, children: isSelected ? "→" : " " }), " ", _jsx(Text, { color: isSelected ? JELLY_COLORS.accent : JELLY_COLORS.header, bold: isSelected, children: item.id }), _jsxs(Text, { color: JELLY_COLORS.muted, children: [" [", item.tier, "]"] }), checkMark] }, item.id));
|
|
84
|
+
})) }), endIdx < filtered.length && (_jsx(Box, { paddingX: 2, children: _jsxs(Text, { color: JELLY_COLORS.dim, children: ["\u00B7\u00B7\u00B7 ", filtered.length - endIdx, " more (scroll down or refine search)"] }) })), _jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { color: JELLY_COLORS.muted, children: [" (", selectedIndex + 1, "/", filtered.length, ")"] }) })] }));
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=ModelSelector.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jellyos/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "JellyOS — standalone AI trading agent. Runs locally, no server required.",
|
|
5
5
|
"author": "JellyChain",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
"dotenv": "^16.4.5",
|
|
35
35
|
"ink": "^5.0.1",
|
|
36
36
|
"ink-text-input": "^6.0.0",
|
|
37
|
-
"react": "^18.3.1"
|
|
38
|
-
"tsx": "^4.15.0"
|
|
37
|
+
"react": "^18.3.1"
|
|
39
38
|
},
|
|
40
39
|
"devDependencies": {
|
|
41
40
|
"@types/node": "^20.19.41",
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ContextStore tests — task lifecycle, findings, auto-delete.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect, afterEach } from "vitest";
|
|
5
|
-
import { ContextStore } from "../session/ContextStore.js";
|
|
6
|
-
import { existsSync } from "node:fs";
|
|
7
|
-
// Use a temp dir for tests
|
|
8
|
-
process.env.JELLYOS_HOME = `/tmp/jellyos-test-${Date.now()}`;
|
|
9
|
-
describe("ContextStore", () => {
|
|
10
|
-
let store;
|
|
11
|
-
afterEach(() => {
|
|
12
|
-
store = new ContextStore(); // fresh store
|
|
13
|
-
});
|
|
14
|
-
it("openTask creates a context.md file", () => {
|
|
15
|
-
store = new ContextStore();
|
|
16
|
-
const ctx = store.openTask("Test task");
|
|
17
|
-
expect(existsSync(ctx.contextMd)).toBe(true);
|
|
18
|
-
expect(ctx.taskId.length).toBeGreaterThan(0);
|
|
19
|
-
expect(ctx.findings).toBe(0);
|
|
20
|
-
});
|
|
21
|
-
it("appendFinding increments findings count", () => {
|
|
22
|
-
store = new ContextStore();
|
|
23
|
-
const ctx = store.openTask("Test");
|
|
24
|
-
store.appendFinding(ctx.taskId, "Price Data", "BTC: $70,000");
|
|
25
|
-
store.appendFinding(ctx.taskId, "RSI", "RSI: 67 bullish");
|
|
26
|
-
expect(store.getTask(ctx.taskId)?.findings).toBe(2);
|
|
27
|
-
});
|
|
28
|
-
it("getReference returns path + count info", () => {
|
|
29
|
-
store = new ContextStore();
|
|
30
|
-
const ctx = store.openTask("Analysis");
|
|
31
|
-
store.appendFinding(ctx.taskId, "Data", "some findings");
|
|
32
|
-
const ref = store.getReference(ctx.taskId);
|
|
33
|
-
expect(ref).toContain("context.md");
|
|
34
|
-
expect(ref).toContain("1 findings");
|
|
35
|
-
expect(ref).toContain(ctx.taskId);
|
|
36
|
-
});
|
|
37
|
-
it("getActiveTasks returns open tasks", () => {
|
|
38
|
-
store = new ContextStore();
|
|
39
|
-
const t1 = store.openTask("Task 1");
|
|
40
|
-
const t2 = store.openTask("Task 2");
|
|
41
|
-
const active = store.getActiveTasks();
|
|
42
|
-
expect(active.map(t => t.taskId)).toContain(t1.taskId);
|
|
43
|
-
expect(active.map(t => t.taskId)).toContain(t2.taskId);
|
|
44
|
-
});
|
|
45
|
-
it("closeTask removes from active tasks", () => {
|
|
46
|
-
store = new ContextStore();
|
|
47
|
-
const ctx = store.openTask("Temp task");
|
|
48
|
-
store.closeTask(ctx.taskId);
|
|
49
|
-
expect(store.getTask(ctx.taskId)).toBeUndefined();
|
|
50
|
-
});
|
|
51
|
-
it("keepTask prevents deletion", () => {
|
|
52
|
-
store = new ContextStore();
|
|
53
|
-
const ctx = store.openTask("Important");
|
|
54
|
-
store.keepTask(ctx.taskId);
|
|
55
|
-
expect(store.getTask(ctx.taskId)?.keep).toBe(true);
|
|
56
|
-
});
|
|
57
|
-
it("readContextTool returns file contents", async () => {
|
|
58
|
-
store = new ContextStore();
|
|
59
|
-
const ctx = store.openTask("Read test");
|
|
60
|
-
store.appendFinding(ctx.taskId, "Finding", "ETH price is $3000");
|
|
61
|
-
const result = await store.readContextTool("", { taskId: ctx.taskId });
|
|
62
|
-
expect(result.content[0]?.text).toContain("ETH price is $3000");
|
|
63
|
-
});
|
|
64
|
-
it("caps individual findings at 3000 chars", () => {
|
|
65
|
-
store = new ContextStore();
|
|
66
|
-
const ctx = store.openTask("Big task");
|
|
67
|
-
const bigContent = "x".repeat(5000);
|
|
68
|
-
store.appendFinding(ctx.taskId, "Big finding", bigContent);
|
|
69
|
-
// Should not throw and should be capped
|
|
70
|
-
const result = store.getReference(ctx.taskId);
|
|
71
|
-
expect(result).toBeTruthy();
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
//# sourceMappingURL=ContextStore.test.js.map
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ModelRegistry tests — tier classification + temperature profiles.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect } from "vitest";
|
|
5
|
-
import { classifyModel } from "../models/ModelRegistry.js";
|
|
6
|
-
function makeModel(id, promptPrice = "0.000005") {
|
|
7
|
-
return {
|
|
8
|
-
id,
|
|
9
|
-
name: id,
|
|
10
|
-
created: Date.now(),
|
|
11
|
-
description: "",
|
|
12
|
-
context_length: 128_000,
|
|
13
|
-
architecture: { modality: "text", tokenizer: "cl100k", instruct_type: null },
|
|
14
|
-
pricing: { prompt: promptPrice, completion: "0.000015" },
|
|
15
|
-
top_provider: { context_length: 128_000, max_completion_tokens: 4096, is_moderated: false },
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
describe("classifyModel() — 2025 models", () => {
|
|
19
|
-
// Orchestrator tier
|
|
20
|
-
it("classifies claude-opus-4.7 as orchestrator", () => {
|
|
21
|
-
expect(classifyModel(makeModel("anthropic/claude-opus-4.7"))).toBe("orchestrator");
|
|
22
|
-
});
|
|
23
|
-
it("classifies claude-opus-4.6 as orchestrator", () => {
|
|
24
|
-
expect(classifyModel(makeModel("anthropic/claude-opus-4.6"))).toBe("orchestrator");
|
|
25
|
-
});
|
|
26
|
-
it("classifies gpt-5.5 as orchestrator", () => {
|
|
27
|
-
expect(classifyModel(makeModel("openai/gpt-5.5"))).toBe("orchestrator");
|
|
28
|
-
});
|
|
29
|
-
it("classifies gpt-5.5-pro as orchestrator", () => {
|
|
30
|
-
expect(classifyModel(makeModel("openai/gpt-5.5-pro"))).toBe("orchestrator");
|
|
31
|
-
});
|
|
32
|
-
it("classifies gemini-3.1-pro as orchestrator", () => {
|
|
33
|
-
expect(classifyModel(makeModel("google/gemini-3.1-pro-preview"))).toBe("orchestrator");
|
|
34
|
-
});
|
|
35
|
-
it("classifies deepseek-v4-pro as orchestrator", () => {
|
|
36
|
-
expect(classifyModel(makeModel("deepseek/deepseek-v4-pro"))).toBe("orchestrator");
|
|
37
|
-
});
|
|
38
|
-
it("classifies grok-4.0 as orchestrator", () => {
|
|
39
|
-
expect(classifyModel(makeModel("x-ai/grok-4.0"))).toBe("orchestrator");
|
|
40
|
-
});
|
|
41
|
-
// Analyst tier
|
|
42
|
-
it("classifies claude-sonnet-4.6 as analyst", () => {
|
|
43
|
-
expect(classifyModel(makeModel("anthropic/claude-sonnet-4.6"))).toBe("analyst");
|
|
44
|
-
});
|
|
45
|
-
it("classifies claude-sonnet-4.5 as analyst", () => {
|
|
46
|
-
expect(classifyModel(makeModel("anthropic/claude-sonnet-4.5"))).toBe("analyst");
|
|
47
|
-
});
|
|
48
|
-
it("classifies gemini-3.5-flash as analyst", () => {
|
|
49
|
-
expect(classifyModel(makeModel("google/gemini-3.5-flash"))).toBe("analyst");
|
|
50
|
-
});
|
|
51
|
-
it("classifies deepseek-v4 (non-pro) as analyst", () => {
|
|
52
|
-
expect(classifyModel(makeModel("deepseek/deepseek-v4-flash"))).toBe("analyst");
|
|
53
|
-
});
|
|
54
|
-
// Free tier
|
|
55
|
-
it("classifies :free models as free", () => {
|
|
56
|
-
expect(classifyModel(makeModel("deepseek/deepseek-v4-flash:free", "0"))).toBe("free");
|
|
57
|
-
});
|
|
58
|
-
it("classifies free-priced models as free", () => {
|
|
59
|
-
expect(classifyModel(makeModel("meta-llama/llama-3.1-8b-instruct:free", "0"))).toBe("free");
|
|
60
|
-
});
|
|
61
|
-
// Worker fallback
|
|
62
|
-
it("classifies unknown model as worker", () => {
|
|
63
|
-
expect(classifyModel(makeModel("some-unknown/model-v1"))).toBe("worker");
|
|
64
|
-
});
|
|
65
|
-
it("classifies small llama as worker", () => {
|
|
66
|
-
expect(classifyModel(makeModel("meta-llama/llama-3.1-8b-instruct", "0.0002"))).toBe("worker");
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
//# sourceMappingURL=ModelRegistry.test.js.map
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SessionManager tests — atomic compaction, context pressure, semantic anchors.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
5
|
-
import { SessionManager } from "../session/SessionManager.js";
|
|
6
|
-
function makeUserTurn(userContent, assistantContent) {
|
|
7
|
-
return [
|
|
8
|
-
{ role: "user", content: userContent },
|
|
9
|
-
{ role: "assistant", content: assistantContent },
|
|
10
|
-
];
|
|
11
|
-
}
|
|
12
|
-
function makeToolTurn(userContent, toolContent) {
|
|
13
|
-
const tc_id = `call_${Math.random().toString(36).slice(2)}`;
|
|
14
|
-
return [
|
|
15
|
-
{ role: "user", content: userContent },
|
|
16
|
-
{ role: "assistant", content: null, tool_calls: [{ id: tc_id, type: "function", function: { name: "get_prices", arguments: "{}" } }] },
|
|
17
|
-
{ role: "tool", content: toolContent, name: "get_prices", tool_call_id: tc_id },
|
|
18
|
-
];
|
|
19
|
-
}
|
|
20
|
-
describe("SessionManager", () => {
|
|
21
|
-
let session;
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
session = new SessionManager();
|
|
24
|
-
session.setSystemPrompt("You are JellyOS.");
|
|
25
|
-
});
|
|
26
|
-
it("getMessages() prepends system prompt", () => {
|
|
27
|
-
session.addMessage({ role: "user", content: "hello" });
|
|
28
|
-
const msgs = session.getMessages();
|
|
29
|
-
expect(msgs[0]?.role).toBe("system");
|
|
30
|
-
expect(msgs[0]?.content).toContain("JellyOS");
|
|
31
|
-
expect(msgs.length).toBe(2);
|
|
32
|
-
});
|
|
33
|
-
it("charCount() counts history chars", () => {
|
|
34
|
-
session.addMessage({ role: "user", content: "hello" });
|
|
35
|
-
expect(session.charCount()).toBe(5);
|
|
36
|
-
});
|
|
37
|
-
it("getContextPressure() returns green for small history", () => {
|
|
38
|
-
session.addMessage({ role: "user", content: "hi" });
|
|
39
|
-
const p = session.getContextPressure();
|
|
40
|
-
expect(p.level).toBe("green");
|
|
41
|
-
expect(p.turboReady).toBe(true);
|
|
42
|
-
expect(p.pct).toBeLessThan(50);
|
|
43
|
-
});
|
|
44
|
-
it("getContextPressure() reports yellow at 60%", () => {
|
|
45
|
-
// Add ~48KB of content (60% of 80KB)
|
|
46
|
-
const bigMsg = "x".repeat(48_000);
|
|
47
|
-
session.addMessage({ role: "user", content: bigMsg });
|
|
48
|
-
const p = session.getContextPressure();
|
|
49
|
-
expect(p.pct).toBeGreaterThanOrEqual(50);
|
|
50
|
-
});
|
|
51
|
-
it("turboReady is false when context >70%", () => {
|
|
52
|
-
const bigMsg = "x".repeat(60_000);
|
|
53
|
-
session.addMessage({ role: "user", content: bigMsg });
|
|
54
|
-
const p = session.getContextPressure();
|
|
55
|
-
expect(p.turboReady).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
it("compaction never orphans tool_call_id pairs", () => {
|
|
58
|
-
// Add enough turns to trigger compaction
|
|
59
|
-
for (let i = 0; i < 20; i++) {
|
|
60
|
-
for (const msg of makeToolTurn(`question ${i}`, `tool result ${i} — ${"data".repeat(200)}`)) {
|
|
61
|
-
session.addMessage(msg);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
const history = session.getHistory();
|
|
65
|
-
// Find all assistant messages with tool_calls
|
|
66
|
-
const toolCallMsgs = history.filter(m => m.tool_calls && m.tool_calls.length > 0);
|
|
67
|
-
for (const tcMsg of toolCallMsgs) {
|
|
68
|
-
for (const tc of tcMsg.tool_calls) {
|
|
69
|
-
// Every tool_call_id must have a corresponding tool result
|
|
70
|
-
const hasResult = history.some(m => m.role === "tool" && m.tool_call_id === tc.id);
|
|
71
|
-
expect(hasResult).toBe(true);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
it("forceCompact() reduces history", () => {
|
|
76
|
-
for (let i = 0; i < 30; i++) {
|
|
77
|
-
for (const msg of makeUserTurn(`q${i}`, `a${i}`)) {
|
|
78
|
-
session.addMessage(msg);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
const before = session.getHistory().length;
|
|
82
|
-
session.forceCompact();
|
|
83
|
-
const after = session.getHistory().length;
|
|
84
|
-
expect(after).toBeLessThanOrEqual(before);
|
|
85
|
-
});
|
|
86
|
-
it("clear() empties history", () => {
|
|
87
|
-
session.addMessage({ role: "user", content: "hello" });
|
|
88
|
-
session.clear();
|
|
89
|
-
expect(session.getHistory().length).toBe(0);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
describe("SessionManager — SwarmRouter integration", () => {
|
|
93
|
-
it("scoreComplexity returns low score for simple price check", async () => {
|
|
94
|
-
const { scoreComplexity } = await import("../runner/SwarmRouter.js");
|
|
95
|
-
expect(scoreComplexity("what is the price of ETH")).toBeLessThan(40);
|
|
96
|
-
});
|
|
97
|
-
it("scoreComplexity returns high score for complex multi-chain analysis", async () => {
|
|
98
|
-
const { scoreComplexity } = await import("../runner/SwarmRouter.js");
|
|
99
|
-
const score = scoreComplexity("analyze ETH and BTC then compare their RSI and predict which will pump first");
|
|
100
|
-
expect(score).toBeGreaterThanOrEqual(40);
|
|
101
|
-
});
|
|
102
|
-
it("decomposeHeuristic splits on conjunctions", async () => {
|
|
103
|
-
const { decomposeHeuristic } = await import("../runner/SwarmRouter.js");
|
|
104
|
-
const tasks = decomposeHeuristic("analyze ETH and check BTC mempool and get SOL TPS", 5);
|
|
105
|
-
expect(tasks.length).toBeGreaterThanOrEqual(2);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
//# sourceMappingURL=SessionManager.test.js.map
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TechnicalAnalysis tests — pure math functions, no network calls.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect } from "vitest";
|
|
5
|
-
import { rsi, macd, ema, sma, bollingerBands, rsiSignal, macdSignal } from "../tools/TechnicalAnalysis.js";
|
|
6
|
-
// Generate price series for testing
|
|
7
|
-
const risingPrices = Array.from({ length: 60 }, (_, i) => 100 + i * 2); // 100→218
|
|
8
|
-
const fallingPrices = Array.from({ length: 60 }, (_, i) => 220 - i * 2); // 220→102
|
|
9
|
-
const flatPrices = Array.from({ length: 60 }, () => 150);
|
|
10
|
-
describe("sma()", () => {
|
|
11
|
-
it("returns empty array when insufficient data", () => {
|
|
12
|
-
expect(sma([100, 99], 14)).toEqual([]);
|
|
13
|
-
});
|
|
14
|
-
it("calculates correct 3-period SMA", () => {
|
|
15
|
-
const result = sma([10, 20, 30, 40], 3);
|
|
16
|
-
expect(result[0]).toBeCloseTo(20, 2); // (10+20+30)/3
|
|
17
|
-
expect(result[1]).toBeCloseTo(30, 2); // (20+30+40)/3
|
|
18
|
-
});
|
|
19
|
-
it("result length = prices.length - period + 1", () => {
|
|
20
|
-
const result = sma(risingPrices, 20);
|
|
21
|
-
expect(result.length).toBe(risingPrices.length - 20 + 1);
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
describe("ema()", () => {
|
|
25
|
-
it("returns first price as first EMA value", () => {
|
|
26
|
-
const result = ema([100, 110, 120], 3);
|
|
27
|
-
expect(result[0]).toBe(100);
|
|
28
|
-
});
|
|
29
|
-
it("EMA tracks price direction", () => {
|
|
30
|
-
const result = ema(risingPrices, 12);
|
|
31
|
-
expect(result[result.length - 1]).toBeGreaterThan(result[0]);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
describe("rsi()", () => {
|
|
35
|
-
it("returns empty array when insufficient data", () => {
|
|
36
|
-
expect(rsi([100, 99], 14)).toEqual([]);
|
|
37
|
-
});
|
|
38
|
-
it("returns oversold (<30) for falling prices", () => {
|
|
39
|
-
const result = rsi(fallingPrices, 14);
|
|
40
|
-
expect(result[result.length - 1]).toBeLessThan(30);
|
|
41
|
-
});
|
|
42
|
-
it("returns overbought (>70) for rising prices", () => {
|
|
43
|
-
const result = rsi(risingPrices, 14);
|
|
44
|
-
expect(result[result.length - 1]).toBeGreaterThan(70);
|
|
45
|
-
});
|
|
46
|
-
it("returns neutral (~50) for flat prices", () => {
|
|
47
|
-
// RSI is undefined for perfectly flat — but should handle gracefully
|
|
48
|
-
const result = rsi(flatPrices, 14);
|
|
49
|
-
// avgLoss = 0, so RSI = 100 (no change)
|
|
50
|
-
expect(result.length).toBeGreaterThan(0);
|
|
51
|
-
});
|
|
52
|
-
it("all values are in [0, 100]", () => {
|
|
53
|
-
const result = rsi(risingPrices, 14);
|
|
54
|
-
for (const v of result) {
|
|
55
|
-
expect(v).toBeGreaterThanOrEqual(0);
|
|
56
|
-
expect(v).toBeLessThanOrEqual(100);
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
describe("rsiSignal()", () => {
|
|
61
|
-
it("returns bearish for overbought RSI", () => {
|
|
62
|
-
const sig = rsiSignal([75]);
|
|
63
|
-
expect(sig.signal).toBe("bearish");
|
|
64
|
-
expect(sig.metadata?.condition).toBe("overbought");
|
|
65
|
-
});
|
|
66
|
-
it("returns bullish for oversold RSI", () => {
|
|
67
|
-
const sig = rsiSignal([25]);
|
|
68
|
-
expect(sig.signal).toBe("bullish");
|
|
69
|
-
expect(sig.metadata?.condition).toBe("oversold");
|
|
70
|
-
});
|
|
71
|
-
it("returns neutral for mid-range RSI", () => {
|
|
72
|
-
const sig = rsiSignal([50]);
|
|
73
|
-
expect(sig.signal).toBe("neutral");
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
describe("macd()", () => {
|
|
77
|
-
it("returns empty arrays for insufficient data", () => {
|
|
78
|
-
const result = macd([100, 99], 12, 26, 9);
|
|
79
|
-
expect(result.signal).toEqual([]);
|
|
80
|
-
});
|
|
81
|
-
it("produces histogram for sufficient data", () => {
|
|
82
|
-
const result = macd(risingPrices, 12, 26, 9);
|
|
83
|
-
expect(result.macd.length).toBeGreaterThan(0);
|
|
84
|
-
expect(result.histogram.length).toBeGreaterThan(0);
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
describe("macdSignal()", () => {
|
|
88
|
-
it("returns neutral for empty histogram", () => {
|
|
89
|
-
expect(macdSignal({ macd: [], signal: [], histogram: [] }).signal).toBe("neutral");
|
|
90
|
-
});
|
|
91
|
-
it("returns bullish when histogram crosses above zero", () => {
|
|
92
|
-
const sig = macdSignal({ macd: [0.1], signal: [0], histogram: [-0.1, 0.1] });
|
|
93
|
-
expect(sig.signal).toBe("bullish");
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
describe("bollingerBands()", () => {
|
|
97
|
-
it("upper band > middle > lower band", () => {
|
|
98
|
-
const bands = bollingerBands(risingPrices, 20, 2);
|
|
99
|
-
const last = bands.upper.length - 1;
|
|
100
|
-
expect(bands.upper[last]).toBeGreaterThan(bands.middle[last]);
|
|
101
|
-
expect(bands.middle[last]).toBeGreaterThan(bands.lower[last]);
|
|
102
|
-
});
|
|
103
|
-
it("flat prices produce narrow bands", () => {
|
|
104
|
-
const bands = bollingerBands(flatPrices, 20, 2);
|
|
105
|
-
const last = bands.width.length - 1;
|
|
106
|
-
expect(bands.width[last]).toBeLessThan(1); // near-zero width for flat prices
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
//# sourceMappingURL=TechnicalAnalysis.test.js.map
|